diff --git a/api.md b/api.md new file mode 100644 index 00000000..953f71c5 --- /dev/null +++ b/api.md @@ -0,0 +1,760 @@ +# Golbat API Documentation + +Golbat provides both HTTP REST and gRPC APIs for querying Pokemon GO data. + +## Table of Contents + +- [Authentication](#authentication) +- [Health Check](#health-check) +- [Raw Data Ingestion](#raw-data-ingestion) +- [Pokemon Endpoints](#pokemon-endpoints) +- [Pokestop Endpoints](#pokestop-endpoints) +- [Gym Endpoints](#gym-endpoints) +- [Quest Endpoints](#quest-endpoints) +- [Tappable Endpoints](#tappable-endpoints) +- [Device Endpoints](#device-endpoints) +- [Debug Endpoints](#debug-endpoints) +- [gRPC API](#grpc-api) +- [Data Structures](#data-structures) + +--- + +## Authentication + +### API Authentication + +All `/api/*` endpoints require authentication via the `X-Golbat-Secret` header. + +``` +X-Golbat-Secret: your_api_secret +``` + +The secret is configured via `api_secret` in the configuration file. + +### Raw Endpoint Authentication + +The `/raw` endpoint optionally supports Bearer token authentication: + +``` +Authorization: Bearer your_raw_bearer_token +``` + +This is only enforced if `raw_bearer` is configured. + +--- + +## Health Check + +### GET /health + +Unrestricted health check endpoint for monitoring. + +**Authentication:** Not required + +**Response:** +```json +{ + "status": "ok" +} +``` + +### GET /api/health + +Authenticated health check endpoint. + +**Authentication:** Required + +**Response:** +```json +{ + "status": "ok" +} +``` + +--- + +## Raw Data Ingestion + +### POST /raw + +Accept raw protobuf data from scanning clients. + +**Authentication:** Bearer token (optional, if configured) + +**Request Body:** +```json +{ + "uuid": "device_uuid", + "username": "account_name", + "trainerlvl": 30, + "scan_context": "context_string", + "lat_target": 40.7128, + "lon_target": -74.0060, + "timestamp_ms": 1234567890, + "have_ar": true, + "contents": [ + { + "payload": "base64_encoded_proto", + "type": 1, + "request": "optional_request_proto" + } + ] +} +``` + +**Response:** HTTP 201 Created (async processing) + +**Notes:** +- Multiple provider formats supported (Pogodroid, standard format) +- Processing timeout: 5s normal, 30s if `extended_timeout` enabled +- Content can use `data` or `payload` for the proto data +- Content can use `method` or `type` for the method number + +--- + +## Pokemon Endpoints + +### GET /api/pokemon/id/:pokemon_id + +Retrieve a single pokemon by encounter ID. + +**Authentication:** Required + +**Parameters:** +| Name | Type | Location | Description | +|------|------|----------|-------------| +| pokemon_id | uint64 | path | Pokemon encounter ID | + +**Response:** [ApiPokemonResult](#apipokemonresult) + +**Status Codes:** +- 200: Pokemon found +- 404: Pokemon not found + +--- + +### GET /api/pokemon/available + +List all available pokemon species with counts. + +**Authentication:** Required + +**Response:** +```json +[ + { + "id": 1, + "form": 0, + "count": 42 + } +] +``` + +--- + +### POST /api/pokemon/scan + +Query pokemon in a geographic area with filters (v1 - legacy). + +**Authentication:** Required + +**Request Body:** +```json +{ + "min": {"lat": 40.7, "lon": -74.0}, + "max": {"lat": 40.8, "lon": -73.9}, + "center": {"lat": 40.75, "lon": -73.95}, + "limit": 500, + "global": { + "iv": [0, 100], + "atk_iv": [0, 15], + "def_iv": [0, 15], + "sta_iv": [0, 15], + "level": [1, 50], + "cp": [0, 3000], + "gender": 1, + "additional": { + "include_everything": false, + "include_hundoiv": true, + "include_zeroiv": false, + "include_xxs": true, + "include_xxl": false + }, + "pvp": { + "little": [1, 100], + "great": [1, 100], + "ultra": [1, 100] + } + }, + "filters": { + "1-0": {} + } +} +``` + +**Response:** Array of [ApiPokemonResult](#apipokemonresult) + +--- + +### POST /api/pokemon/v2/scan + +Query pokemon with DNF (Disjunctive Normal Form) filters - more efficient filtering. + +**Authentication:** Required + +**Request Body:** +```json +{ + "min": {"lat": 40.7, "lon": -74.0}, + "max": {"lat": 40.8, "lon": -73.9}, + "limit": 500, + "filters": [ + { + "pokemon": [{"id": 1, "form": 0}], + "iv": {"min": 90, "max": 100}, + "atk_iv": {"min": 10, "max": 15}, + "def_iv": {"min": 10, "max": 15}, + "sta_iv": {"min": 10, "max": 15}, + "level": {"min": 30, "max": 50}, + "cp": {"min": 2000, "max": 3000}, + "gender": {"min": 0, "max": 2}, + "size": {"min": 0, "max": 5}, + "pvp_little": {"min": 1, "max": 100}, + "pvp_great": {"min": 1, "max": 100}, + "pvp_ultra": {"min": 1, "max": 100} + } + ] +} +``` + +**Response:** Array of [ApiPokemonResult](#apipokemonresult) + +--- + +### POST /api/pokemon/v3/scan + +Query pokemon with advanced DNF filters, returns metadata about scan. + +**Authentication:** Required + +**Request Body:** Same as v2, with gender as array + +**Response:** +```json +{ + "pokemon": [], + "examined": 1000, + "skipped": 50, + "total": 1050 +} +``` + +--- + +### POST /api/pokemon/search + +Advanced search using center point and distance. + +**Authentication:** Required + +**Request Body:** +```json +{ + "min": {"lat": 40.7, "lon": -74.0}, + "max": {"lat": 40.8, "lon": -73.9}, + "center": {"lat": 40.75, "lon": -73.95}, + "limit": 500, + "searchIds": [1, 4, 7] +} +``` + +**Response:** Array of [ApiPokemonResult](#apipokemonresult) + +**Status Codes:** +- 200: Success +- 400: Bad Request (validation failed) + +--- + +## Pokestop Endpoints + +### GET /api/pokestop/id/:fort_id + +Retrieve a single pokestop by fort ID. + +**Authentication:** Required + +**Parameters:** +| Name | Type | Location | Description | +|------|------|----------|-------------| +| fort_id | string | path | Pokestop fort ID | + +**Response:** [ApiPokestopResult](#apipokestopresult) + +**Status Codes:** +- 200: Pokestop found +- 404: Pokestop not found + +--- + +### POST /api/pokestop-positions + +Get coordinates of all pokestops within a geofence. + +**Authentication:** Required + +**Request Body:** GeoJSON Feature, Geometry, or Golbat Geofence format +```json +{ + "fence": [ + {"lat": 40.7, "lon": -74.0}, + {"lat": 40.8, "lon": -74.0}, + {"lat": 40.8, "lon": -73.9}, + {"lat": 40.7, "lon": -73.9} + ] +} +``` + +**Response:** +```json +[ + { + "id": "fort_id", + "latitude": 40.7128, + "longitude": -74.0060 + } +] +``` + +--- + +## Gym Endpoints + +### GET /api/gym/id/:gym_id + +Retrieve a single gym by gym ID. + +**Authentication:** Required + +**Parameters:** +| Name | Type | Location | Description | +|------|------|----------|-------------| +| gym_id | string | path | Gym ID | + +**Response:** [ApiGymResult](#apigymresult) + +**Status Codes:** +- 200: Gym found +- 404: Gym not found + +--- + +### POST /api/gym/query + +Get multiple gyms by IDs. + +**Authentication:** Required + +**Request Body:** +```json +{ + "ids": ["gym_id1", "gym_id2"] +} +``` +Or as an array: +```json +["gym_id1", "gym_id2"] +``` + +**Response:** Array of [ApiGymResult](#apigymresult) + +**Limits:** +- Maximum 500 IDs per request +- Duplicates are filtered + +**Status Codes:** +- 200: Success +- 413: Request Entity Too Large (exceeds 500 IDs) + +--- + +### POST /api/gym/search + +Advanced gym search with filters. + +**Authentication:** Required + +**Request Body:** +```json +{ + "filters": [ + { + "name": "central park", + "description": "playground", + "location_distance": { + "location": {"lat": 40.7829, "lon": -73.9654}, + "distance": 500 + }, + "bbox": { + "min_lon": -74.0, + "min_lat": 40.7, + "max_lon": -73.9, + "max_lat": 40.8 + } + } + ], + "limit": 100 +} +``` + +**Response:** Array of [ApiGymResult](#apigymresult) + +**Limits:** +- Default limit: 500 +- Max limit: 10,000 +- Max distance: 500,000 meters + +**Status Codes:** +- 200: Success +- 400: Bad Request (invalid filters) +- 504: Gateway Timeout + +--- + +## Quest Endpoints + +### POST /api/quest-status + +Get quest statistics for a geofence area. + +**Authentication:** Required + +**Request Body:** GeoJSON Feature, Geometry, or Golbat Geofence format + +**Response:** +```json +{ + "ar_quests": 50, + "no_ar_quests": 100, + "total": 200 +} +``` + +--- + +### POST /api/clear-quests +### DELETE /api/clear-quests + +Clear all quests within a geofence area. + +**Authentication:** Required + +**Request Body:** GeoJSON Feature, Geometry, or Golbat Geofence format + +**Response:** +```json +{ + "status": "ok" +} +``` + +--- + +### POST /api/reload-geojson +### GET /api/reload-geojson + +Reload geofence boundaries and clear stats. + +**Authentication:** Required + +**Response:** +```json +{ + "status": "ok" +} +``` + +--- + +## Tappable Endpoints + +### GET /api/tappable/id/:tappable_id + +Retrieve a tappable (invasions, research, etc.). + +**Authentication:** Required + +**Parameters:** +| Name | Type | Location | Description | +|------|------|----------|-------------| +| tappable_id | uint64 | path | Tappable ID | + +**Response:** [ApiTappableResult](#apitappableresult) + +**Status Codes:** +- 200: Tappable found +- 400: Invalid ID +- 404: Tappable not found + +--- + +## Device Endpoints + +### GET /api/devices/all + +Get information about all connected/known devices. + +**Authentication:** Required + +**Response:** +```json +{ + "devices": [ + { + "uuid": "device_uuid", + "lat": 40.7128, + "lon": -74.0060, + "last_scan": 1234567890 + } + ] +} +``` + +--- + +## Debug Endpoints + +These endpoints are only available if `tuning.profile_routes` is enabled in configuration. + +**Authentication:** Required + +| Endpoint | Description | +|----------|-------------| +| GET /debug/pprof/cmdline | Command line arguments | +| GET /debug/pprof/heap | Heap memory profile | +| GET /debug/pprof/block | Block profile | +| GET /debug/pprof/mutex | Mutex profile | +| GET /debug/pprof/trace | Execution trace | +| GET /debug/pprof/profile | CPU profile | +| GET /debug/pprof/symbol | Symbol lookup | + +--- + +## gRPC API + +Golbat also provides a gRPC API running on a separate port (configured via `grpc_port`). + +### Authentication + +Use the `authorization` metadata header with the API secret. + +### Pokemon Service + +```protobuf +service Pokemon { + rpc Search(PokemonScanRequest) returns (PokemonScanResponse); + rpc SearchV3(PokemonScanRequestV3) returns (PokemonScanResponseV3); +} +``` + +The gRPC endpoints mirror the HTTP v2/v3 scan endpoints. + +--- + +## Data Structures + +### Location + +```json +{ + "lat": 40.7128, + "lon": -74.0060 +} +``` + +### Bounding Box (Bbox) + +```json +{ + "min_lon": -74.0, + "min_lat": 40.7, + "max_lon": -73.9, + "max_lat": 40.8 +} +``` + +### ApiPokemonResult + +```json +{ + "id": "encounter_id", + "pokestop_id": "fort_id_or_null", + "spawn_id": 123456789, + "lat": 40.7128, + "lon": -74.0060, + "weight": 5.5, + "size": 2, + "height": 0.8, + "expire_timestamp": 1234567890, + "updated": 1234567800, + "pokemon_id": 1, + "move_1": 100, + "move_2": 200, + "gender": 1, + "cp": 500, + "atk_iv": 15, + "def_iv": 15, + "sta_iv": 15, + "iv": 100.0, + "form": 0, + "level": 30, + "weather": 1, + "costume": 0, + "first_seen_timestamp": 1234567000, + "changed": 1234567800, + "cell_id": 123456789, + "expire_timestamp_verified": true, + "display_pokemon_id": 1, + "is_ditto": false, + "seen_type": "encounter", + "shiny": false, + "username": "trainer_name", + "capture_1": 0.5, + "capture_2": 0.6, + "capture_3": 0.7, + "pvp": {}, + "is_event": 0 +} +``` + +### ApiPokestopResult + +```json +{ + "id": "fort_id", + "lat": 40.7128, + "lon": -74.0060, + "name": "Pokestop Name", + "url": "image_url", + "lure_expire_timestamp": 1234567890, + "last_modified_timestamp": 1234567800, + "updated": 1234567800, + "enabled": true, + "quest_type": 1, + "quest_timestamp": 1234567800, + "quest_target": 3, + "quest_conditions": "json_conditions", + "quest_rewards": "json_rewards", + "quest_template": "template_string", + "quest_title": "Quest Title", + "quest_expiry": 1234667800, + "cell_id": 123456789, + "deleted": false, + "lure_id": 501, + "first_seen_timestamp": 1234567000, + "sponsor_id": 1, + "partner_id": "partner_code", + "ar_scan_eligible": 1, + "power_up_level": 1, + "power_up_points": 100, + "power_up_end_timestamp": 1234567890, + "alternative_quest_type": null, + "alternative_quest_timestamp": null, + "alternative_quest_target": null, + "alternative_quest_conditions": null, + "alternative_quest_rewards": null, + "alternative_quest_template": null, + "alternative_quest_title": null, + "alternative_quest_expiry": null, + "description": "Pokestop description", + "showcase_focus": "focus_pokemon", + "showcase_pokemon_id": 1, + "showcase_pokemon_form_id": 0, + "showcase_pokemon_type_id": 1, + "showcase_ranking_standard": 1, + "showcase_expiry": 1234567890, + "showcase_rankings": "json_rankings" +} +``` + +### ApiGymResult + +```json +{ + "id": "gym_id", + "lat": 40.7128, + "lon": -74.0060, + "name": "Gym Name", + "url": "image_url", + "last_modified_timestamp": 1234567800, + "raid_end_timestamp": 1234567890, + "raid_spawn_timestamp": 1234567800, + "raid_battle_timestamp": 1234567850, + "updated": 1234567800, + "raid_pokemon_id": 1, + "guarding_pokemon_id": 25, + "guarding_pokemon_display": "display_string", + "available_slots": 3, + "team_id": 1, + "raid_level": 3, + "enabled": 1, + "ex_raid_eligible": 1, + "in_battle": 0, + "raid_pokemon_move_1": 100, + "raid_pokemon_move_2": 200, + "raid_pokemon_form": 0, + "raid_pokemon_alignment": 1, + "raid_pokemon_cp": 30000, + "raid_is_exclusive": 0, + "cell_id": 123456789, + "deleted": false, + "total_cp": 150000, + "first_seen_timestamp": 1234567000, + "raid_pokemon_gender": 1, + "sponsor_id": 1, + "partner_id": "partner_code", + "raid_pokemon_costume": 0, + "raid_pokemon_evolution": 0, + "ar_scan_eligible": 1, + "power_up_level": 1, + "power_up_points": 100, + "power_up_end_timestamp": 1234567890, + "description": "Gym description", + "defenders": "json_defenders", + "rsvps": "json_rsvps" +} +``` + +### ApiTappableResult + +```json +{ + "id": 1234567890, + "lat": 40.7128, + "lon": -74.0060, + "fort_id": "gym_or_pokestop_id", + "spawn_id": 987654321, + "type": "invasion", + "pokemon_id": 1, + "item_id": 1, + "count": 1, + "expire_timestamp": 1234567890, + "expire_timestamp_verified": true, + "updated": 1234567800 +} +``` + +--- + +## Configuration Reference + +| Key | Description | +|-----|-------------| +| `api_secret` | API authentication token (header: `X-Golbat-Secret`) | +| `raw_bearer` | Bearer token for raw endpoint (header: `Authorization: Bearer`) | +| `port` | HTTP server port | +| `grpc_port` | gRPC server port | +| `tuning.extended_timeout` | Enable 30s timeout for raw processing | +| `tuning.profile_routes` | Enable pprof debug endpoints | +| `tuning.max_pokemon_results` | Max pokemon returned per query | +| `tuning.max_pokemon_distance` | Max distance between min/max points in searches | diff --git a/config.toml.example b/config.toml.example index 561410c4..81b9e971 100644 --- a/config.toml.example +++ b/config.toml.example @@ -19,6 +19,14 @@ stats_days = 7 # Remove entries from "pokemon_stats", "pokemon_ device_hours = 24 # Remove devices from in memory after not seen for x hours forts_stale_threshold = 3600 # Seconds before a fort is considered stale (default: 1 hour) +[tuning] +# When enabled, reduce_updates will make fort update debounce windows much longer +# to reduce database churn. Specifically, gym/pokestop/station debounce will be +# extended from 15 minutes (900s) to 12 hours (43200s) and spawnpoint last_seen +# will be updated every 12 hours instead of the default 6 hours. +reduce_updates = false + + [logging] debug = false save_logs = true diff --git a/config/config.go b/config/config.go index 09463131..6eacd7da 100644 --- a/config/config.go +++ b/config/config.go @@ -7,26 +7,25 @@ import ( ) type configDefinition struct { - Port int `koanf:"port"` - GrpcPort int `koanf:"grpc_port"` - Webhooks []Webhook `koanf:"webhooks"` - Database database `koanf:"database"` - Logging logging `koanf:"logging"` - Sentry sentry `koanf:"sentry"` - Pyroscope pyroscope `koanf:"pyroscope"` - Prometheus Prometheus `koanf:"prometheus"` - PokemonMemoryOnly bool `koanf:"pokemon_memory_only"` - PokemonInternalToDb bool `koanf:"pokemon_internal_to_db"` - TestFortInMemory bool `koanf:"test_fort_in_memory"` - Cleanup cleanup `koanf:"cleanup"` - RawBearer string `koanf:"raw_bearer"` - ApiSecret string `koanf:"api_secret"` - Pvp pvp `koanf:"pvp"` - Koji koji `koanf:"koji"` - Tuning tuning `koanf:"tuning"` - Weather weather `koanf:"weather"` - ScanRules []scanRule `koanf:"scan_rules"` - MaxConcurrentProactiveIVSwitch int `koanf:"max_concurrent_proactive_iv_switch"` + Port int `koanf:"port"` + GrpcPort int `koanf:"grpc_port"` + Webhooks []Webhook `koanf:"webhooks"` + Database database `koanf:"database"` + Logging logging `koanf:"logging"` + Sentry sentry `koanf:"sentry"` + Pyroscope pyroscope `koanf:"pyroscope"` + Prometheus Prometheus `koanf:"prometheus"` + PokemonMemoryOnly bool `koanf:"pokemon_memory_only"` + PokemonInternalToDb bool `koanf:"pokemon_internal_to_db"` + TestFortInMemory bool `koanf:"test_fort_in_memory"` + Cleanup cleanup `koanf:"cleanup"` + RawBearer string `koanf:"raw_bearer"` + ApiSecret string `koanf:"api_secret"` + Pvp pvp `koanf:"pvp"` + Koji koji `koanf:"koji"` + Tuning tuning `koanf:"tuning"` + Weather weather `koanf:"weather"` + ScanRules []scanRule `koanf:"scan_rules"` } func (configDefinition configDefinition) GetWebhookInterval() time.Duration { @@ -120,10 +119,12 @@ type database struct { } type tuning struct { - ExtendedTimeout bool `koanf:"extended_timeout"` - MaxPokemonResults int `koanf:"max_pokemon_results"` - MaxPokemonDistance float64 `koanf:"max_pokemon_distance"` - ProfileRoutes bool `koanf:"profile_routes"` + ExtendedTimeout bool `koanf:"extended_timeout"` + MaxPokemonResults int `koanf:"max_pokemon_results"` + MaxPokemonDistance float64 `koanf:"max_pokemon_distance"` + ProfileRoutes bool `koanf:"profile_routes"` + MaxConcurrentProactiveIVSwitch int `koanf:"max_concurrent_proactive_iv_switch"` + ReduceUpdates bool `koanf:"reduce_updates"` } type scanRule struct { @@ -133,6 +134,7 @@ type scanRule struct { ProcessPokemon *bool `koanf:"pokemon"` ProcessWilds *bool `koanf:"wild_pokemon"` ProcessNearby *bool `koanf:"nearby_pokemon"` + ProcessNearbyCell *bool `koanf:"nearby_cell_pokemon"` ProcessWeather *bool `koanf:"weather"` ProcessCells *bool `koanf:"cells"` ProcessPokestops *bool `koanf:"pokestops"` diff --git a/config/reader.go b/config/reader.go index 270edc93..57935895 100644 --- a/config/reader.go +++ b/config/reader.go @@ -51,8 +51,10 @@ func ReadConfig() (configDefinition, error) { MaxPool: 100, }, Tuning: tuning{ - MaxPokemonResults: 3000, - MaxPokemonDistance: 100, + MaxPokemonResults: 3000, + MaxPokemonDistance: 100, + MaxConcurrentProactiveIVSwitch: 6, + ReduceUpdates: false, }, Weather: weather{ ProactiveIVSwitching: true, @@ -61,7 +63,6 @@ func ReadConfig() (configDefinition, error) { Pvp: pvp{ LevelCaps: []int{50, 51}, }, - MaxConcurrentProactiveIVSwitch: 6, }, "koanf"), nil) if defaultErr != nil { fmt.Println(fmt.Errorf("failed to load default config: %w", defaultErr)) diff --git a/decode.go b/decode.go new file mode 100644 index 00000000..daab1cb3 --- /dev/null +++ b/decode.go @@ -0,0 +1,738 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "golbat/decoder" + "golbat/pogo" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" +) + +func decode(ctx context.Context, method int, protoData *ProtoData) { + getMethodName := func(method int, trimString bool) string { + if val, ok := pogo.Method_name[int32(method)]; ok { + if trimString && strings.HasPrefix(val, "METHOD_") { + return strings.TrimPrefix(val, "METHOD_") + } + return val + } + return fmt.Sprintf("#%d", method) + } + + if method != int(pogo.InternalPlatformClientAction_INTERNAL_PROXY_SOCIAL_ACTION) && protoData.Level < 30 { + statsCollector.IncDecodeMethods("error", "low_level", getMethodName(method, true)) + log.Debugf("Insufficient Level %d Did not process hook type %s", protoData.Level, pogo.Method(method)) + return + } + + processed := false + ignore := false + start := time.Now() + result := "" + + switch pogo.Method(method) { + case pogo.Method_METHOD_START_INCIDENT: + result = decodeStartIncident(ctx, protoData.Data) + processed = true + case pogo.Method_METHOD_INVASION_OPEN_COMBAT_SESSION: + if protoData.Request != nil { + result = decodeOpenInvasion(ctx, protoData.Request, protoData.Data) + processed = true + } + case pogo.Method_METHOD_FORT_DETAILS: + result = decodeFortDetails(ctx, protoData.Data) + processed = true + case pogo.Method_METHOD_GET_MAP_OBJECTS: + result = decodeGMO(ctx, protoData, getScanParameters(protoData)) + processed = true + case pogo.Method_METHOD_GYM_GET_INFO: + result = decodeGetGymInfo(ctx, protoData.Data) + processed = true + case pogo.Method_METHOD_ENCOUNTER: + if getScanParameters(protoData).ProcessPokemon { + result = decodeEncounter(ctx, protoData.Data, protoData.Account, protoData.TimestampMs) + } + processed = true + case pogo.Method_METHOD_DISK_ENCOUNTER: + result = decodeDiskEncounter(ctx, protoData.Data, protoData.Account) + processed = true + case pogo.Method_METHOD_FORT_SEARCH: + result = decodeQuest(ctx, protoData.Data, protoData.HaveAr) + processed = true + case pogo.Method_METHOD_GET_PLAYER: + ignore = true + case pogo.Method_METHOD_GET_HOLOHOLO_INVENTORY: + ignore = true + case pogo.Method_METHOD_CREATE_COMBAT_CHALLENGE: + ignore = true + case pogo.Method(pogo.InternalPlatformClientAction_INTERNAL_PROXY_SOCIAL_ACTION): + if protoData.Request != nil { + result = decodeSocialActionWithRequest(protoData.Request, protoData.Data) + processed = true + } + case pogo.Method_METHOD_GET_MAP_FORTS: + result = decodeGetMapForts(ctx, protoData.Data) + processed = true + case pogo.Method_METHOD_GET_ROUTES: + result = decodeGetRoutes(ctx, protoData.Data) + processed = true + case pogo.Method_METHOD_GET_CONTEST_DATA: + if getScanParameters(protoData).ProcessPokestops { + // Request helps, but can be decoded without it + result = decodeGetContestData(ctx, protoData.Request, protoData.Data) + } + processed = true + case pogo.Method_METHOD_GET_POKEMON_SIZE_CONTEST_ENTRY: + // Request is essential to decode this + if protoData.Request != nil { + if getScanParameters(protoData).ProcessPokestops { + result = decodeGetPokemonSizeContestEntry(ctx, protoData.Request, protoData.Data) + } + processed = true + } + case pogo.Method_METHOD_GET_STATION_DETAILS: + if getScanParameters(protoData).ProcessStations { + // Request is essential to decode this + result = decodeGetStationDetails(ctx, protoData.Request, protoData.Data) + } + processed = true + case pogo.Method_METHOD_PROCESS_TAPPABLE: + if getScanParameters(protoData).ProcessTappables { + // Request is essential to decode this + result = decodeTappable(ctx, protoData.Request, protoData.Data, protoData.Account, protoData.TimestampMs) + } + processed = true + case pogo.Method_METHOD_GET_EVENT_RSVPS: + if getScanParameters(protoData).ProcessGyms { + result = decodeGetEventRsvp(ctx, protoData.Request, protoData.Data) + } + processed = true + case pogo.Method_METHOD_GET_EVENT_RSVP_COUNT: + if getScanParameters(protoData).ProcessGyms { + result = decodeGetEventRsvpCount(ctx, protoData.Data) + } + processed = true + default: + log.Debugf("Did not know hook type %s", pogo.Method(method)) + } + if !ignore { + elapsed := time.Since(start) + if processed == true { + statsCollector.IncDecodeMethods("ok", "", getMethodName(method, true)) + log.Debugf("%s/%s %s - %s - %s", protoData.Uuid, protoData.Account, pogo.Method(method), elapsed, result) + } else { + log.Debugf("%s/%s %s - %s - %s", protoData.Uuid, protoData.Account, pogo.Method(method), elapsed, "**Did not process**") + statsCollector.IncDecodeMethods("unprocessed", "", getMethodName(method, true)) + } + } +} + +func getScanParameters(protoData *ProtoData) decoder.ScanParameters { + return decoder.FindScanConfiguration(protoData.ScanContext, protoData.Lat, protoData.Lon) +} + +func decodeQuest(ctx context.Context, sDec []byte, haveAr *bool) string { + if haveAr == nil { + statsCollector.IncDecodeQuest("error", "missing_ar_info") + log.Infoln("Cannot determine AR quest - ignoring") + // We should either assume AR quest, or trace inventory like RDM probably + return "No AR quest info" + } + decodedQuest := &pogo.FortSearchOutProto{} + if err := proto.Unmarshal(sDec, decodedQuest); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeQuest("error", "parse") + return "Parse failure" + } + + if decodedQuest.Result != pogo.FortSearchOutProto_SUCCESS { + statsCollector.IncDecodeQuest("error", "non_success") + res := fmt.Sprintf(`GymGetInfoOutProto: Ignored non-success value %d:%s`, decodedQuest.Result, + pogo.FortSearchOutProto_Result_name[int32(decodedQuest.Result)]) + return res + } + + return decoder.UpdatePokestopWithQuest(ctx, dbDetails, decodedQuest, *haveAr) + +} + +func decodeSocialActionWithRequest(request []byte, payload []byte) string { + var proxyRequestProto pogo.ProxyRequestProto + + if err := proto.Unmarshal(request, &proxyRequestProto); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeSocialActionWithRequest("error", "request_parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + var proxyResponseProto pogo.ProxyResponseProto + + if err := proto.Unmarshal(payload, &proxyResponseProto); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeSocialActionWithRequest("error", "response_parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if proxyResponseProto.Status != pogo.ProxyResponseProto_COMPLETED && proxyResponseProto.Status != pogo.ProxyResponseProto_COMPLETED_AND_REASSIGNED { + statsCollector.IncDecodeSocialActionWithRequest("error", "non_success") + return fmt.Sprintf("unsuccessful proxyResponseProto response %d %s", int(proxyResponseProto.Status), proxyResponseProto.Status) + } + + switch pogo.InternalSocialAction(proxyRequestProto.GetAction()) { + case pogo.InternalSocialAction_SOCIAL_ACTION_LIST_FRIEND_STATUS: + statsCollector.IncDecodeSocialActionWithRequest("ok", "list_friend_status") + return decodeGetFriendDetails(proxyResponseProto.Payload) + case pogo.InternalSocialAction_SOCIAL_ACTION_SEARCH_PLAYER: + statsCollector.IncDecodeSocialActionWithRequest("ok", "search_player") + return decodeSearchPlayer(&proxyRequestProto, proxyResponseProto.Payload) + + } + + statsCollector.IncDecodeSocialActionWithRequest("ok", "unknown") + return fmt.Sprintf("Did not process %s", pogo.InternalSocialAction(proxyRequestProto.GetAction()).String()) +} + +func decodeGetFriendDetails(payload []byte) string { + var getFriendDetailsOutProto pogo.InternalGetFriendDetailsOutProto + getFriendDetailsError := proto.Unmarshal(payload, &getFriendDetailsOutProto) + + if getFriendDetailsError != nil { + statsCollector.IncDecodeGetFriendDetails("error", "parse") + log.Errorf("Failed to parse %s", getFriendDetailsError) + return fmt.Sprintf("Failed to parse %s", getFriendDetailsError) + } + + if getFriendDetailsOutProto.GetResult() != pogo.InternalGetFriendDetailsOutProto_SUCCESS || getFriendDetailsOutProto.GetFriend() == nil { + statsCollector.IncDecodeGetFriendDetails("error", "non_success") + return fmt.Sprintf("unsuccessful get friends details") + } + + failures := 0 + + for _, friend := range getFriendDetailsOutProto.GetFriend() { + player := friend.GetPlayer() + + updatePlayerError := decoder.UpdatePlayerRecordWithPlayerSummary(dbDetails, player, player.PublicData, "", player.GetPlayerId()) + if updatePlayerError != nil { + failures++ + } + } + + statsCollector.IncDecodeGetFriendDetails("ok", "") + return fmt.Sprintf("%d players decoded on %d", len(getFriendDetailsOutProto.GetFriend())-failures, len(getFriendDetailsOutProto.GetFriend())) +} + +func decodeSearchPlayer(proxyRequestProto *pogo.ProxyRequestProto, payload []byte) string { + var searchPlayerOutProto pogo.InternalSearchPlayerOutProto + searchPlayerOutError := proto.Unmarshal(payload, &searchPlayerOutProto) + + if searchPlayerOutError != nil { + log.Errorf("Failed to parse %s", searchPlayerOutError) + statsCollector.IncDecodeSearchPlayer("error", "parse") + return fmt.Sprintf("Failed to parse %s", searchPlayerOutError) + } + + if searchPlayerOutProto.GetResult() != pogo.InternalSearchPlayerOutProto_SUCCESS || searchPlayerOutProto.GetPlayer() == nil { + statsCollector.IncDecodeSearchPlayer("error", "non_success") + return fmt.Sprintf("unsuccessful search player response") + } + + var searchPlayerProto pogo.InternalSearchPlayerProto + searchPlayerError := proto.Unmarshal(proxyRequestProto.GetPayload(), &searchPlayerProto) + + if searchPlayerError != nil || searchPlayerProto.GetFriendCode() == "" { + statsCollector.IncDecodeSearchPlayer("error", "parse") + return fmt.Sprintf("Failed to parse %s", searchPlayerError) + } + + player := searchPlayerOutProto.GetPlayer() + updatePlayerError := decoder.UpdatePlayerRecordWithPlayerSummary(dbDetails, player, player.PublicData, searchPlayerProto.GetFriendCode(), "") + if updatePlayerError != nil { + statsCollector.IncDecodeSearchPlayer("error", "update") + return fmt.Sprintf("Failed update player %s", updatePlayerError) + } + + statsCollector.IncDecodeSearchPlayer("ok", "") + return fmt.Sprintf("1 player decoded from SearchPlayerProto") +} + +func decodeFortDetails(ctx context.Context, sDec []byte) string { + decodedFort := &pogo.FortDetailsOutProto{} + if err := proto.Unmarshal(sDec, decodedFort); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeFortDetails("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + switch decodedFort.FortType { + case pogo.FortType_CHECKPOINT: + statsCollector.IncDecodeFortDetails("ok", "pokestop") + return decoder.UpdatePokestopRecordWithFortDetailsOutProto(ctx, dbDetails, decodedFort) + case pogo.FortType_GYM: + statsCollector.IncDecodeFortDetails("ok", "gym") + return decoder.UpdateGymRecordWithFortDetailsOutProto(ctx, dbDetails, decodedFort) + } + + statsCollector.IncDecodeFortDetails("ok", "unknown") + return "Unknown fort type" +} + +func decodeGetMapForts(ctx context.Context, sDec []byte) string { + decodedMapForts := &pogo.GetMapFortsOutProto{} + if err := proto.Unmarshal(sDec, decodedMapForts); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeGetMapForts("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedMapForts.Status != pogo.GetMapFortsOutProto_SUCCESS { + statsCollector.IncDecodeGetMapForts("error", "non_success") + res := fmt.Sprintf(`GetMapFortsOutProto: Ignored non-success value %d:%s`, decodedMapForts.Status, + pogo.GetMapFortsOutProto_Status_name[int32(decodedMapForts.Status)]) + return res + } + + statsCollector.IncDecodeGetMapForts("ok", "") + var outputString string + processedForts := 0 + + for _, fort := range decodedMapForts.Fort { + status, output := decoder.UpdateFortRecordWithGetMapFortsOutProto(ctx, dbDetails, fort) + if status { + processedForts += 1 + outputString += output + ", " + } + } + + if processedForts > 0 { + return fmt.Sprintf("Updated %d forts: %s", processedForts, outputString) + } + return "No forts updated" +} + +func decodeGetRoutes(ctx context.Context, payload []byte) string { + getRoutesOutProto := &pogo.GetRoutesOutProto{} + if err := proto.Unmarshal(payload, getRoutesOutProto); err != nil { + return fmt.Sprintf("failed to decode GetRoutesOutProto %s", err) + } + + if getRoutesOutProto.Status != pogo.GetRoutesOutProto_SUCCESS { + return fmt.Sprintf("GetRoutesOutProto: Ignored non-success value %d:%s", getRoutesOutProto.Status, getRoutesOutProto.Status.String()) + } + + decodeSuccesses := map[string]bool{} + decodeErrors := map[string]bool{} + + for _, routeMapCell := range getRoutesOutProto.GetRouteMapCell() { + for _, route := range routeMapCell.GetRoute() { + //TODO we need to check the repeated field, for now access last element + routeSubmissionStatus := route.RouteSubmissionStatus[len(route.RouteSubmissionStatus)-1] + if routeSubmissionStatus != nil && routeSubmissionStatus.Status != pogo.RouteSubmissionStatus_PUBLISHED { + log.Warnf("Non published Route found in GetRoutesOutProto, status: %s", routeSubmissionStatus.String()) + continue + } + decodeError := decoder.UpdateRouteRecordWithSharedRouteProto(ctx, dbDetails, route) + if decodeError != nil { + if decodeErrors[route.Id] != true { + decodeErrors[route.Id] = true + } + log.Errorf("Failed to decode route %s", decodeError) + } else if decodeSuccesses[route.Id] != true { + decodeSuccesses[route.Id] = true + } + } + } + + return fmt.Sprintf( + "Decoded %d routes, failed to decode %d routes, from %d cells", + len(decodeSuccesses), + len(decodeErrors), + len(getRoutesOutProto.GetRouteMapCell()), + ) +} + +func decodeGetGymInfo(ctx context.Context, sDec []byte) string { + decodedGymInfo := &pogo.GymGetInfoOutProto{} + if err := proto.Unmarshal(sDec, decodedGymInfo); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeGetGymInfo("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedGymInfo.Result != pogo.GymGetInfoOutProto_SUCCESS { + statsCollector.IncDecodeGetGymInfo("error", "non_success") + res := fmt.Sprintf(`GymGetInfoOutProto: Ignored non-success value %d:%s`, decodedGymInfo.Result, + pogo.GymGetInfoOutProto_Result_name[int32(decodedGymInfo.Result)]) + return res + } + + statsCollector.IncDecodeGetGymInfo("ok", "") + return decoder.UpdateGymRecordWithGymInfoProto(ctx, dbDetails, decodedGymInfo) +} + +func decodeEncounter(ctx context.Context, sDec []byte, username string, timestampMs int64) string { + decodedEncounterInfo := &pogo.EncounterOutProto{} + if err := proto.Unmarshal(sDec, decodedEncounterInfo); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeEncounter("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedEncounterInfo.Status != pogo.EncounterOutProto_ENCOUNTER_SUCCESS { + statsCollector.IncDecodeEncounter("error", "non_success") + res := fmt.Sprintf(`EncounterOutProto: Ignored non-success value %d:%s`, decodedEncounterInfo.Status, + pogo.EncounterOutProto_Status_name[int32(decodedEncounterInfo.Status)]) + return res + } + + statsCollector.IncDecodeEncounter("ok", "") + return decoder.UpdatePokemonRecordWithEncounterProto(ctx, dbDetails, decodedEncounterInfo, username, timestampMs) +} + +func decodeDiskEncounter(ctx context.Context, sDec []byte, username string) string { + decodedEncounterInfo := &pogo.DiskEncounterOutProto{} + if err := proto.Unmarshal(sDec, decodedEncounterInfo); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeDiskEncounter("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedEncounterInfo.Result != pogo.DiskEncounterOutProto_SUCCESS { + statsCollector.IncDecodeDiskEncounter("error", "non_success") + res := fmt.Sprintf(`DiskEncounterOutProto: Ignored non-success value %d:%s`, decodedEncounterInfo.Result, + pogo.DiskEncounterOutProto_Result_name[int32(decodedEncounterInfo.Result)]) + return res + } + + statsCollector.IncDecodeDiskEncounter("ok", "") + return decoder.UpdatePokemonRecordWithDiskEncounterProto(ctx, dbDetails, decodedEncounterInfo, username) +} + +func decodeStartIncident(ctx context.Context, sDec []byte) string { + decodedIncident := &pogo.StartIncidentOutProto{} + if err := proto.Unmarshal(sDec, decodedIncident); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeStartIncident("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedIncident.Status != pogo.StartIncidentOutProto_SUCCESS { + statsCollector.IncDecodeStartIncident("error", "non_success") + res := fmt.Sprintf(`GiovanniOutProto: Ignored non-success value %d:%s`, decodedIncident.Status, + pogo.StartIncidentOutProto_Status_name[int32(decodedIncident.Status)]) + return res + } + + statsCollector.IncDecodeStartIncident("ok", "") + return decoder.ConfirmIncident(ctx, dbDetails, decodedIncident) +} + +func decodeOpenInvasion(ctx context.Context, request []byte, payload []byte) string { + decodeOpenInvasionRequest := &pogo.OpenInvasionCombatSessionProto{} + + if err := proto.Unmarshal(request, decodeOpenInvasionRequest); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeOpenInvasion("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + if decodeOpenInvasionRequest.IncidentLookup == nil { + return "Invalid OpenInvasionCombatSessionProto received" + } + + decodedOpenInvasionResponse := &pogo.OpenInvasionCombatSessionOutProto{} + if err := proto.Unmarshal(payload, decodedOpenInvasionResponse); err != nil { + log.Errorf("Failed to parse %s", err) + statsCollector.IncDecodeOpenInvasion("error", "parse") + return fmt.Sprintf("Failed to parse %s", err) + } + + if decodedOpenInvasionResponse.Status != pogo.InvasionStatus_SUCCESS { + statsCollector.IncDecodeOpenInvasion("error", "non_success") + res := fmt.Sprintf(`InvasionLineupOutProto: Ignored non-success value %d:%s`, decodedOpenInvasionResponse.Status, + pogo.InvasionStatus_Status_name[int32(decodedOpenInvasionResponse.Status)]) + return res + } + + statsCollector.IncDecodeOpenInvasion("ok", "") + return decoder.UpdateIncidentLineup(ctx, dbDetails, decodeOpenInvasionRequest, decodedOpenInvasionResponse) +} + +func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder.ScanParameters) string { + decodedGmo := &pogo.GetMapObjectsOutProto{} + + if err := proto.Unmarshal(protoData.Data, decodedGmo); err != nil { + statsCollector.IncDecodeGMO("error", "parse") + log.Errorf("Failed to parse %s", err) + } + + if decodedGmo.Status != pogo.GetMapObjectsOutProto_SUCCESS { + statsCollector.IncDecodeGMO("error", "non_success") + res := fmt.Sprintf(`GetMapObjectsOutProto: Ignored non-success value %d:%s`, decodedGmo.Status, + pogo.GetMapObjectsOutProto_Status_name[int32(decodedGmo.Status)]) + return res + } + + var newForts []decoder.RawFortData + var newStations []decoder.RawStationData + var newWildPokemon []decoder.RawWildPokemonData + var newNearbyPokemon []decoder.RawNearbyPokemonData + var newMapPokemon []decoder.RawMapPokemonData + var newMapCells []uint64 + var cellsToBeCleaned []uint64 + + // track forts per cell for memory-based cleanup (only if tracker enabled) + cellForts := make(map[uint64]*decoder.FortTrackerGMOContents) + + if len(decodedGmo.MapCell) == 0 { + return "Skipping GetMapObjectsOutProto: No map cells found" + } + for _, mapCell := range decodedGmo.MapCell { + // initialize cell forts tracking for every map cell (so empty fort lists are seen as "no forts") + cellForts[mapCell.S2CellId] = &decoder.FortTrackerGMOContents{ + Pokestops: make([]string, 0), + Gyms: make([]string, 0), + Timestamp: mapCell.AsOfTimeMs, + } + // always mark this mapCell to be checked for removed forts. Previously only cells with forts were + // added which meant an empty fort list (all forts removed) was never passed to the tracker. + cellsToBeCleaned = append(cellsToBeCleaned, mapCell.S2CellId) + + if isCellNotEmpty(mapCell) { + newMapCells = append(newMapCells, mapCell.S2CellId) + } + + for _, fort := range mapCell.Fort { + newForts = append(newForts, decoder.RawFortData{Cell: mapCell.S2CellId, Data: fort, Timestamp: mapCell.AsOfTimeMs}) + + // track fort by type for memory-based cleanup (only if tracker enabled) + if cf, ok := cellForts[mapCell.S2CellId]; ok { + switch fort.FortType { + case pogo.FortType_GYM: + cf.Gyms = append(cf.Gyms, fort.FortId) + case pogo.FortType_CHECKPOINT: + cf.Pokestops = append(cf.Pokestops, fort.FortId) + } + } + + if fort.ActivePokemon != nil { + newMapPokemon = append(newMapPokemon, decoder.RawMapPokemonData{Cell: mapCell.S2CellId, Data: fort.ActivePokemon, Timestamp: mapCell.AsOfTimeMs}) + } + } + for _, mon := range mapCell.WildPokemon { + newWildPokemon = append(newWildPokemon, decoder.RawWildPokemonData{Cell: mapCell.S2CellId, Data: mon, Timestamp: mapCell.AsOfTimeMs}) + } + for _, mon := range mapCell.NearbyPokemon { + newNearbyPokemon = append(newNearbyPokemon, decoder.RawNearbyPokemonData{Cell: mapCell.S2CellId, Data: mon, Timestamp: mapCell.AsOfTimeMs}) + } + for _, station := range mapCell.Stations { + newStations = append(newStations, decoder.RawStationData{Cell: mapCell.S2CellId, Data: station}) + } + } + + if scanParameters.ProcessGyms || scanParameters.ProcessPokestops { + decoder.UpdateFortBatch(ctx, dbDetails, scanParameters, newForts) + } + var weatherUpdates []decoder.WeatherUpdate + if scanParameters.ProcessWeather { + weatherUpdates = decoder.UpdateClientWeatherBatch(ctx, dbDetails, decodedGmo.ClientWeather, decodedGmo.MapCell[0].AsOfTimeMs, protoData.Account) + } + if scanParameters.ProcessPokemon { + decoder.UpdatePokemonBatch(ctx, dbDetails, scanParameters, newWildPokemon, newNearbyPokemon, newMapPokemon, decodedGmo.ClientWeather, protoData.Account) + if scanParameters.ProcessWeather && scanParameters.ProactiveIVSwitching { + for _, weatherUpdate := range weatherUpdates { + go func(weatherUpdate decoder.WeatherUpdate) { + decoder.ProactiveIVSwitchSem <- true + defer func() { <-decoder.ProactiveIVSwitchSem }() + decoder.ProactiveIVSwitch(ctx, dbDetails, weatherUpdate, scanParameters.ProactiveIVSwitchingToDB, decodedGmo.MapCell[0].AsOfTimeMs/1000) + }(weatherUpdate) + } + } + } + if scanParameters.ProcessStations { + decoder.UpdateStationBatch(ctx, dbDetails, scanParameters, newStations) + } + + if scanParameters.ProcessCells { + decoder.UpdateClientMapS2CellBatch(ctx, dbDetails, newMapCells) + } + + if scanParameters.ProcessGyms || scanParameters.ProcessPokestops { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + decoder.CheckRemovedForts(ctx, dbDetails, cellsToBeCleaned, cellForts) + }() + } + + newFortsLen := len(newForts) + newStationsLen := len(newStations) + newWildPokemonLen := len(newWildPokemon) + newNearbyPokemonLen := len(newNearbyPokemon) + newMapPokemonLen := len(newMapPokemon) + newClientWeatherLen := len(decodedGmo.ClientWeather) + newMapCellsLen := len(newMapCells) + + statsCollector.IncDecodeGMO("ok", "") + statsCollector.AddDecodeGMOType("fort", float64(newFortsLen)) + statsCollector.AddDecodeGMOType("station", float64(newStationsLen)) + statsCollector.AddDecodeGMOType("wild_pokemon", float64(newWildPokemonLen)) + statsCollector.AddDecodeGMOType("nearby_pokemon", float64(newNearbyPokemonLen)) + statsCollector.AddDecodeGMOType("map_pokemon", float64(newMapPokemonLen)) + statsCollector.AddDecodeGMOType("weather", float64(newClientWeatherLen)) + statsCollector.AddDecodeGMOType("cell", float64(newMapCellsLen)) + + return fmt.Sprintf("%d cells containing %d forts %d stations %d mon %d nearby", newMapCellsLen, newFortsLen, newStationsLen, newWildPokemonLen, newNearbyPokemonLen) +} + +func isCellNotEmpty(mapCell *pogo.ClientMapCellProto) bool { + return len(mapCell.Stations) > 0 || len(mapCell.Fort) > 0 || len(mapCell.WildPokemon) > 0 || len(mapCell.NearbyPokemon) > 0 || len(mapCell.CatchablePokemon) > 0 +} + +func cellContainsForts(mapCell *pogo.ClientMapCellProto) bool { + return len(mapCell.Fort) > 0 +} + +func decodeGetContestData(ctx context.Context, request []byte, data []byte) string { + var decodedContestData pogo.GetContestDataOutProto + if err := proto.Unmarshal(data, &decodedContestData); err != nil { + log.Errorf("Failed to parse GetContestDataOutProto %s", err) + return fmt.Sprintf("Failed to parse GetContestDataOutProto %s", err) + } + + var decodedContestDataRequest pogo.GetContestDataProto + if request != nil { + if err := proto.Unmarshal(request, &decodedContestDataRequest); err != nil { + log.Errorf("Failed to parse GetContestDataProto %s", err) + return fmt.Sprintf("Failed to parse GetContestDataProto %s", err) + } + } + return decoder.UpdatePokestopWithContestData(ctx, dbDetails, &decodedContestDataRequest, &decodedContestData) +} + +func decodeGetPokemonSizeContestEntry(ctx context.Context, request []byte, data []byte) string { + var decodedPokemonSizeContestEntry pogo.GetPokemonSizeLeaderboardEntryOutProto + if err := proto.Unmarshal(data, &decodedPokemonSizeContestEntry); err != nil { + log.Errorf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) + return fmt.Sprintf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) + } + + if decodedPokemonSizeContestEntry.Status != pogo.GetPokemonSizeLeaderboardEntryOutProto_SUCCESS { + return fmt.Sprintf("Ignored GetPokemonSizeLeaderboardEntryOutProto non-success status %s", decodedPokemonSizeContestEntry.Status) + } + + var decodedPokemonSizeContestEntryRequest pogo.GetPokemonSizeLeaderboardEntryProto + if request != nil { + if err := proto.Unmarshal(request, &decodedPokemonSizeContestEntryRequest); err != nil { + log.Errorf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) + return fmt.Sprintf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) + } + } + + return decoder.UpdatePokestopWithPokemonSizeContestEntry(ctx, dbDetails, &decodedPokemonSizeContestEntryRequest, &decodedPokemonSizeContestEntry) +} + +func decodeGetStationDetails(ctx context.Context, request []byte, data []byte) string { + var decodedGetStationDetails pogo.GetStationedPokemonDetailsOutProto + if err := proto.Unmarshal(data, &decodedGetStationDetails); err != nil { + log.Errorf("Failed to parse GetStationedPokemonDetailsOutProto %s", err) + return fmt.Sprintf("Failed to parse GetStationedPokemonDetailsOutProto %s", err) + } + + var decodedGetStationDetailsRequest pogo.GetStationedPokemonDetailsProto + if request != nil { + if err := proto.Unmarshal(request, &decodedGetStationDetailsRequest); err != nil { + log.Errorf("Failed to parse GetStationedPokemonDetailsProto %s", err) + return fmt.Sprintf("Failed to parse GetStationedPokemonDetailsProto %s", err) + } + } + + if decodedGetStationDetails.Result == pogo.GetStationedPokemonDetailsOutProto_STATION_NOT_FOUND { + // station without stationed pokemon found, therefore we need to reset the columns + return decoder.ResetStationedPokemonWithStationDetailsNotFound(ctx, dbDetails, &decodedGetStationDetailsRequest) + } else if decodedGetStationDetails.Result != pogo.GetStationedPokemonDetailsOutProto_SUCCESS { + return fmt.Sprintf("Ignored GetStationedPokemonDetailsOutProto non-success status %s", decodedGetStationDetails.Result) + } + + return decoder.UpdateStationWithStationDetails(ctx, dbDetails, &decodedGetStationDetailsRequest, &decodedGetStationDetails) +} + +func decodeTappable(ctx context.Context, request, data []byte, username string, timestampMs int64) string { + var tappable pogo.ProcessTappableOutProto + if err := proto.Unmarshal(data, &tappable); err != nil { + log.Errorf("Failed to parse %s", err) + return fmt.Sprintf("Failed to parse ProcessTappableOutProto %s", err) + } + + var tappableRequest pogo.ProcessTappableProto + if request != nil { + if err := proto.Unmarshal(request, &tappableRequest); err != nil { + log.Errorf("Failed to parse %s", err) + return fmt.Sprintf("Failed to parse ProcessTappableProto %s", err) + } + } + + if tappable.Status != pogo.ProcessTappableOutProto_SUCCESS { + return fmt.Sprintf("Ignored ProcessTappableOutProto non-success status %s", tappable.Status) + } + var result string + if encounter := tappable.GetEncounter(); encounter != nil { + result = decoder.UpdatePokemonRecordWithTappableEncounter(ctx, dbDetails, &tappableRequest, encounter, username, timestampMs) + } + return result + " " + decoder.UpdateTappable(ctx, dbDetails, &tappableRequest, &tappable, timestampMs) +} + +func decodeGetEventRsvp(ctx context.Context, request []byte, data []byte) string { + var rsvp pogo.GetEventRsvpsOutProto + if err := proto.Unmarshal(data, &rsvp); err != nil { + log.Errorf("Failed to parse %s", err) + return fmt.Sprintf("Failed to parse GetEventRsvpsOutProto %s", err) + } + + var rsvpRequest pogo.GetEventRsvpsProto + if request != nil { + if err := proto.Unmarshal(request, &rsvpRequest); err != nil { + log.Errorf("Failed to parse %s", err) + return fmt.Sprintf("Failed to parse GetEventRsvpsProto %s", err) + } + } + + if rsvp.Status != pogo.GetEventRsvpsOutProto_SUCCESS { + return fmt.Sprintf("Ignored GetEventRsvpsOutProto non-success status %s", rsvp.Status) + } + + switch op := rsvpRequest.EventDetails.(type) { + case *pogo.GetEventRsvpsProto_Raid: + return decoder.UpdateGymRecordWithRsvpProto(ctx, dbDetails, op.Raid, &rsvp) + case *pogo.GetEventRsvpsProto_GmaxBattle: + return "Unsupported GmaxBattle Rsvp received" + } + + return "Failed to parse GetEventRsvpsProto - unknown event type" +} + +func decodeGetEventRsvpCount(ctx context.Context, data []byte) string { + var rsvp pogo.GetEventRsvpCountOutProto + if err := proto.Unmarshal(data, &rsvp); err != nil { + log.Errorf("Failed to parse %s", err) + return fmt.Sprintf("Failed to parse GetEventRsvpCountOutProto %s", err) + } + + if rsvp.Status != pogo.GetEventRsvpCountOutProto_SUCCESS { + return fmt.Sprintf("Ignored GetEventRsvpCountOutProto non-success status %s", rsvp.Status) + } + + var clearLocations []string + for _, rsvpDetails := range rsvp.RsvpDetails { + if rsvpDetails.MaybeCount == 0 && rsvpDetails.GoingCount == 0 { + clearLocations = append(clearLocations, rsvpDetails.LocationId) + decoder.ClearGymRsvp(ctx, dbDetails, rsvpDetails.LocationId) + } + } + + return "Cleared RSVP @ " + strings.Join(clearLocations, ", ") +} diff --git a/decoder/api_gym.go b/decoder/api_gym.go index 97bd60b9..8003cde3 100644 --- a/decoder/api_gym.go +++ b/decoder/api_gym.go @@ -8,8 +8,104 @@ import ( "golbat/db" "golbat/geo" + + "github.com/guregu/null/v6" ) +type ApiGymResult struct { + Id string `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Name null.String `json:"name"` + Url null.String `json:"url"` + LastModifiedTimestamp null.Int `json:"last_modified_timestamp"` + RaidEndTimestamp null.Int `json:"raid_end_timestamp"` + RaidSpawnTimestamp null.Int `json:"raid_spawn_timestamp"` + RaidBattleTimestamp null.Int `json:"raid_battle_timestamp"` + Updated int64 `json:"updated"` + RaidPokemonId null.Int `json:"raid_pokemon_id"` + GuardingPokemonId null.Int `json:"guarding_pokemon_id"` + GuardingPokemonDisplay null.String `json:"guarding_pokemon_display"` + AvailableSlots null.Int `json:"available_slots"` + TeamId null.Int `json:"team_id"` + RaidLevel null.Int `json:"raid_level"` + Enabled null.Int `json:"enabled"` + ExRaidEligible null.Int `json:"ex_raid_eligible"` + InBattle null.Int `json:"in_battle"` + RaidPokemonMove1 null.Int `json:"raid_pokemon_move_1"` + RaidPokemonMove2 null.Int `json:"raid_pokemon_move_2"` + RaidPokemonForm null.Int `json:"raid_pokemon_form"` + RaidPokemonAlignment null.Int `json:"raid_pokemon_alignment"` + RaidPokemonCp null.Int `json:"raid_pokemon_cp"` + RaidIsExclusive null.Int `json:"raid_is_exclusive"` + CellId null.Int `json:"cell_id"` + Deleted bool `json:"deleted"` + TotalCp null.Int `json:"total_cp"` + FirstSeenTimestamp int64 `json:"first_seen_timestamp"` + RaidPokemonGender null.Int `json:"raid_pokemon_gender"` + SponsorId null.Int `json:"sponsor_id"` + PartnerId null.String `json:"partner_id"` + RaidPokemonCostume null.Int `json:"raid_pokemon_costume"` + RaidPokemonEvolution null.Int `json:"raid_pokemon_evolution"` + ArScanEligible null.Int `json:"ar_scan_eligible"` + PowerUpLevel null.Int `json:"power_up_level"` + PowerUpPoints null.Int `json:"power_up_points"` + PowerUpEndTimestamp null.Int `json:"power_up_end_timestamp"` + Description null.String `json:"description"` + Defenders null.String `json:"defenders"` + Rsvps null.String `json:"rsvps"` +} + +func buildGymResult(gym *Gym) ApiGymResult { + return ApiGymResult{ + Id: gym.Id, + Lat: gym.Lat, + Lon: gym.Lon, + Name: gym.Name, + Url: gym.Url, + LastModifiedTimestamp: gym.LastModifiedTimestamp, + RaidEndTimestamp: gym.RaidEndTimestamp, + RaidSpawnTimestamp: gym.RaidSpawnTimestamp, + RaidBattleTimestamp: gym.RaidBattleTimestamp, + Updated: gym.Updated, + RaidPokemonId: gym.RaidPokemonId, + GuardingPokemonId: gym.GuardingPokemonId, + GuardingPokemonDisplay: gym.GuardingPokemonDisplay, + AvailableSlots: gym.AvailableSlots, + TeamId: gym.TeamId, + RaidLevel: gym.RaidLevel, + Enabled: gym.Enabled, + ExRaidEligible: gym.ExRaidEligible, + InBattle: gym.InBattle, + RaidPokemonMove1: gym.RaidPokemonMove1, + RaidPokemonMove2: gym.RaidPokemonMove2, + RaidPokemonForm: gym.RaidPokemonForm, + RaidPokemonAlignment: gym.RaidPokemonAlignment, + RaidPokemonCp: gym.RaidPokemonCp, + RaidIsExclusive: gym.RaidIsExclusive, + CellId: gym.CellId, + Deleted: gym.Deleted, + TotalCp: gym.TotalCp, + FirstSeenTimestamp: gym.FirstSeenTimestamp, + RaidPokemonGender: gym.RaidPokemonGender, + SponsorId: gym.SponsorId, + PartnerId: gym.PartnerId, + RaidPokemonCostume: gym.RaidPokemonCostume, + RaidPokemonEvolution: gym.RaidPokemonEvolution, + ArScanEligible: gym.ArScanEligible, + PowerUpLevel: gym.PowerUpLevel, + PowerUpPoints: gym.PowerUpPoints, + PowerUpEndTimestamp: gym.PowerUpEndTimestamp, + Description: gym.Description, + Defenders: gym.Defenders, + Rsvps: gym.Rsvps, + } +} + +func BuildGymResult(gym *Gym) ApiGymResult { + return buildGymResult(gym) +} + type ApiGymSearch struct { Limit int `json:"limit"` Filters []ApiGymSearchFilter `json:"filters"` diff --git a/decoder/api_pokemon.go b/decoder/api_pokemon.go index 0126cad4..6a414eb6 100644 --- a/decoder/api_pokemon.go +++ b/decoder/api_pokemon.go @@ -83,9 +83,9 @@ func haversine(start, end geo.Location) float64 { return earthRadiusKm * c } -func SearchPokemon(request ApiPokemonSearch) ([]*Pokemon, error) { +func SearchPokemon(request ApiPokemonSearch) ([]*ApiPokemonResult, error) { start := time.Now() - results := make([]*Pokemon, 0, request.Limit) + results := make([]uint64, 0, request.Limit) pokemonMatched := 0 if request.SearchIds == nil { @@ -109,6 +109,7 @@ func SearchPokemon(request ApiPokemonSearch) ([]*Pokemon, error) { if maxDistance == 0 { maxDistance = 10 } + pokemonTree2.Nearby( rtree.BoxDist[float64, uint64]([2]float64{request.Center.Longitude, request.Center.Latitude}, [2]float64{request.Center.Longitude, request.Center.Latitude}, nil), func(min, max [2]float64, pokemonId uint64, dist float64) bool { @@ -128,15 +129,12 @@ func SearchPokemon(request ApiPokemonSearch) ([]*Pokemon, error) { found := slices.Contains(request.SearchIds, pokemonLookupItem.PokemonLookup.PokemonId) if found { - if pokemonCacheEntry := getPokemonFromCache(pokemonId); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - results = append(results, &pokemon) - pokemonMatched++ - - if pokemonMatched > maxPokemon { - log.Infof("SearchPokemon - result would exceed maximum size (%d), stopping scan", maxPokemon) - return false - } + results = append(results, pokemonId) + pokemonMatched++ + + if pokemonMatched > maxPokemon { + log.Infof("SearchPokemon - result would exceed maximum size (%d), stopping scan", maxPokemon) + return false } } @@ -145,15 +143,28 @@ func SearchPokemon(request ApiPokemonSearch) ([]*Pokemon, error) { ) log.Infof("SearchPokemon - scanned %d pokemon, total time %s, %d returned", pokemonScanned, time.Since(start), len(results)) - return results, nil + + apiResults := make([]*ApiPokemonResult, 0, len(results)) + + for _, encounterId := range results { + pokemon, unlock, _ := peekPokemonRecordReadOnly(encounterId) + if pokemon != nil { + apiPokemon := buildApiPokemonResult(pokemon) + apiResults = append(apiResults, &apiPokemon) + unlock() + } + } + + return apiResults, nil } // Get one result func GetOnePokemon(pokemonId uint64) *ApiPokemonResult { - if item := getPokemonFromCache(pokemonId); item != nil { - pokemon := item.Value() - apiPokemon := buildApiPokemonResult(&pokemon) + item, unlock, _ := peekPokemonRecordReadOnly(pokemonId) + if item != nil { + apiPokemon := buildApiPokemonResult(item) + defer unlock() return &apiPokemon } return nil diff --git a/decoder/api_pokemon_common.go b/decoder/api_pokemon_common.go index 9eb74919..c9eeca16 100644 --- a/decoder/api_pokemon_common.go +++ b/decoder/api_pokemon_common.go @@ -10,8 +10,8 @@ import ( pb "golbat/grpc" "github.com/UnownHash/gohbem" + "github.com/guregu/null/v6" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) type ApiPokemonDnfId struct { diff --git a/decoder/api_pokemon_scan_v1.go b/decoder/api_pokemon_scan_v1.go index 737393ad..a3de7d4b 100644 --- a/decoder/api_pokemon_scan_v1.go +++ b/decoder/api_pokemon_scan_v1.go @@ -227,10 +227,10 @@ func GetPokemonInArea(retrieveParameters ApiPokemonScan) []*ApiPokemonResult { results := make([]*ApiPokemonResult, 0, len(returnKeys)) for _, key := range returnKeys { - if pokemonCacheEntry := getPokemonFromCache(key); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - - apiPokemon := buildApiPokemonResult(&pokemon) + pokemon, unlock, _ := peekPokemonRecordReadOnly(key) + if pokemon != nil { + apiPokemon := buildApiPokemonResult(pokemon) + unlock() results = append(results, &apiPokemon) } } diff --git a/decoder/api_pokemon_scan_v2.go b/decoder/api_pokemon_scan_v2.go index 878f3ab5..ae029c90 100644 --- a/decoder/api_pokemon_scan_v2.go +++ b/decoder/api_pokemon_scan_v2.go @@ -103,16 +103,14 @@ func GetPokemonInArea2(retrieveParameters ApiPokemonScan2) []*ApiPokemonResult { startUnix := start.Unix() for _, key := range returnKeys { - if pokemonCacheEntry := getPokemonFromCache(key); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - - if pokemon.ExpireTimestamp.ValueOrZero() < startUnix { - continue + pokemon, unlock, _ := peekPokemonRecordReadOnly(key) + if pokemon != nil { + if pokemon.ExpireTimestamp.ValueOrZero() > startUnix { + apiPokemon := buildApiPokemonResult(pokemon) + results = append(results, &apiPokemon) } + unlock() - apiPokemon := buildApiPokemonResult(&pokemon) - - results = append(results, &apiPokemon) } } @@ -185,22 +183,20 @@ func GrpcGetPokemonInArea2(retrieveParameters *pb.PokemonScanRequest) []*pb.Poke startUnix := start.Unix() for _, key := range returnKeys { - if pokemonCacheEntry := getPokemonFromCache(key); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - - if pokemon.ExpireTimestamp.ValueOrZero() < startUnix { - continue - } - - apiPokemon := pb.PokemonDetails{ - Id: pokemon.Id, - PokestopId: pokemon.PokestopId.Ptr(), - SpawnId: pokemon.SpawnId.Ptr(), - Lat: pokemon.Lat, - Lon: pokemon.Lon, + pokemon, unlock, _ := peekPokemonRecordReadOnly(key) + if pokemon != nil { + if pokemon.ExpireTimestamp.ValueOrZero() > startUnix { + apiPokemon := pb.PokemonDetails{ + Id: pokemon.Id, + PokestopId: pokemon.PokestopId.Ptr(), + SpawnId: pokemon.SpawnId.Ptr(), + Lat: pokemon.Lat, + Lon: pokemon.Lon, + } + results = append(results, &apiPokemon) } - results = append(results, &apiPokemon) + unlock() } } diff --git a/decoder/api_pokemon_scan_v3.go b/decoder/api_pokemon_scan_v3.go index 8c5ef4ad..6cb5e220 100644 --- a/decoder/api_pokemon_scan_v3.go +++ b/decoder/api_pokemon_scan_v3.go @@ -110,17 +110,13 @@ func GetPokemonInArea3(retrieveParameters ApiPokemonScan3) *PokemonScan3Result { startUnix := start.Unix() for _, key := range returnKeys { - if pokemonCacheEntry := getPokemonFromCache(key); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - - if pokemon.ExpireTimestamp.ValueOrZero() < startUnix { - examined-- - continue + pokemon, unlock, _ := peekPokemonRecordReadOnly(key) + if pokemon != nil { + if pokemon.ExpireTimestamp.ValueOrZero() > startUnix { + apiPokemon := buildApiPokemonResult(pokemon) + results = append(results, &apiPokemon) } - - apiPokemon := buildApiPokemonResult(&pokemon) - - results = append(results, &apiPokemon) + unlock() } } @@ -204,22 +200,20 @@ func GrpcGetPokemonInArea3(retrieveParameters *pb.PokemonScanRequestV3) ([]*pb.P startUnix := start.Unix() for _, key := range returnKeys { - if pokemonCacheEntry := getPokemonFromCache(key); pokemonCacheEntry != nil { - pokemon := pokemonCacheEntry.Value() - - if pokemon.ExpireTimestamp.ValueOrZero() < startUnix { - continue - } - - apiPokemon := pb.PokemonDetails{ - Id: pokemon.Id, - PokestopId: pokemon.PokestopId.Ptr(), - SpawnId: pokemon.SpawnId.Ptr(), - Lat: pokemon.Lat, - Lon: pokemon.Lon, + pokemon, unlock, _ := peekPokemonRecordReadOnly(key) + if pokemon != nil { + if pokemon.ExpireTimestamp.ValueOrZero() > startUnix { + apiPokemon := pb.PokemonDetails{ + Id: pokemon.Id, + PokestopId: pokemon.PokestopId.Ptr(), + SpawnId: pokemon.SpawnId.Ptr(), + Lat: pokemon.Lat, + Lon: pokemon.Lon, + } + results = append(results, &apiPokemon) } - results = append(results, &apiPokemon) + unlock() } } diff --git a/decoder/api_pokestop.go b/decoder/api_pokestop.go new file mode 100644 index 00000000..618c81d4 --- /dev/null +++ b/decoder/api_pokestop.go @@ -0,0 +1,101 @@ +package decoder + +import "github.com/guregu/null/v6" + +type ApiPokestopResult struct { + Id string `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Name null.String `json:"name"` + Url null.String `json:"url"` + LureExpireTimestamp null.Int `json:"lure_expire_timestamp"` + LastModifiedTimestamp null.Int `json:"last_modified_timestamp"` + Updated int64 `json:"updated"` + Enabled null.Bool `json:"enabled"` + QuestType null.Int `json:"quest_type"` + QuestTimestamp null.Int `json:"quest_timestamp"` + QuestTarget null.Int `json:"quest_target"` + QuestConditions null.String `json:"quest_conditions"` + QuestRewards null.String `json:"quest_rewards"` + QuestTemplate null.String `json:"quest_template"` + QuestTitle null.String `json:"quest_title"` + QuestExpiry null.Int `json:"quest_expiry"` + CellId null.Int `json:"cell_id"` + Deleted bool `json:"deleted"` + LureId int16 `json:"lure_id"` + FirstSeenTimestamp int16 `json:"first_seen_timestamp"` + SponsorId null.Int `json:"sponsor_id"` + PartnerId null.String `json:"partner_id"` + ArScanEligible null.Int `json:"ar_scan_eligible"` + PowerUpLevel null.Int `json:"power_up_level"` + PowerUpPoints null.Int `json:"power_up_points"` + PowerUpEndTimestamp null.Int `json:"power_up_end_timestamp"` + AlternativeQuestType null.Int `json:"alternative_quest_type"` + AlternativeQuestTimestamp null.Int `json:"alternative_quest_timestamp"` + AlternativeQuestTarget null.Int `json:"alternative_quest_target"` + AlternativeQuestConditions null.String `json:"alternative_quest_conditions"` + AlternativeQuestRewards null.String `json:"alternative_quest_rewards"` + AlternativeQuestTemplate null.String `json:"alternative_quest_template"` + AlternativeQuestTitle null.String `json:"alternative_quest_title"` + AlternativeQuestExpiry null.Int `json:"alternative_quest_expiry"` + Description null.String `json:"description"` + ShowcaseFocus null.String `json:"showcase_focus"` + ShowcasePokemon null.Int `json:"showcase_pokemon_id"` + ShowcasePokemonForm null.Int `json:"showcase_pokemon_form_id"` + ShowcasePokemonType null.Int `json:"showcase_pokemon_type_id"` + ShowcaseRankingStandard null.Int `json:"showcase_ranking_standard"` + ShowcaseExpiry null.Int `json:"showcase_expiry"` + ShowcaseRankings null.String `json:"showcase_rankings"` +} + +func buildPokestopResult(stop *Pokestop) ApiPokestopResult { + return ApiPokestopResult{ + Id: stop.Id, + Lat: stop.Lat, + Lon: stop.Lon, + Name: stop.Name, + Url: stop.Url, + LureExpireTimestamp: stop.LureExpireTimestamp, + LastModifiedTimestamp: stop.LastModifiedTimestamp, + Updated: stop.Updated, + Enabled: stop.Enabled, + QuestType: stop.QuestType, + QuestTimestamp: stop.QuestTimestamp, + QuestTarget: stop.QuestTarget, + QuestConditions: stop.QuestConditions, + QuestRewards: stop.QuestRewards, + QuestTemplate: stop.QuestTemplate, + QuestTitle: stop.QuestTitle, + QuestExpiry: stop.QuestExpiry, + CellId: stop.CellId, + Deleted: stop.Deleted, + LureId: stop.LureId, + FirstSeenTimestamp: stop.FirstSeenTimestamp, + SponsorId: stop.SponsorId, + PartnerId: stop.PartnerId, + ArScanEligible: stop.ArScanEligible, + PowerUpLevel: stop.PowerUpLevel, + PowerUpPoints: stop.PowerUpPoints, + PowerUpEndTimestamp: stop.PowerUpEndTimestamp, + AlternativeQuestType: stop.AlternativeQuestType, + AlternativeQuestTimestamp: stop.AlternativeQuestTimestamp, + AlternativeQuestTarget: stop.AlternativeQuestTarget, + AlternativeQuestConditions: stop.AlternativeQuestConditions, + AlternativeQuestRewards: stop.AlternativeQuestRewards, + AlternativeQuestTemplate: stop.AlternativeQuestTemplate, + AlternativeQuestTitle: stop.AlternativeQuestTitle, + AlternativeQuestExpiry: stop.AlternativeQuestExpiry, + Description: stop.Description, + ShowcaseFocus: stop.ShowcaseFocus, + ShowcasePokemon: stop.ShowcasePokemon, + ShowcasePokemonForm: stop.ShowcasePokemonForm, + ShowcasePokemonType: stop.ShowcasePokemonType, + ShowcaseRankingStandard: stop.ShowcaseRankingStandard, + ShowcaseExpiry: stop.ShowcaseExpiry, + ShowcaseRankings: stop.ShowcaseRankings, + } +} + +func BuildPokestopResult(stop *Pokestop) ApiPokestopResult { + return buildPokestopResult(stop) +} diff --git a/decoder/api_tappable.go b/decoder/api_tappable.go new file mode 100644 index 00000000..96874b65 --- /dev/null +++ b/decoder/api_tappable.go @@ -0,0 +1,39 @@ +package decoder + +import "github.com/guregu/null/v6" + +type ApiTappableResult struct { + Id uint64 `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + FortId null.String `json:"fort_id"` + SpawnId null.Int `json:"spawn_id"` + Type string `json:"type"` + Encounter null.Int `json:"pokemon_id"` + ItemId null.Int `json:"item_id"` + Count null.Int `json:"count"` + ExpireTimestamp null.Int `json:"expire_timestamp"` + ExpireTimestampVerified bool `json:"expire_timestamp_verified"` + Updated int64 `json:"updated"` +} + +func buildTappableResult(tappable *Tappable) ApiTappableResult { + return ApiTappableResult{ + Id: tappable.Id, + Lat: tappable.Lat, + Lon: tappable.Lon, + FortId: tappable.FortId, + SpawnId: tappable.SpawnId, + Type: tappable.Type, + Encounter: tappable.Encounter, + ItemId: tappable.ItemId, + Count: tappable.Count, + ExpireTimestamp: tappable.ExpireTimestamp, + ExpireTimestampVerified: tappable.ExpireTimestampVerified, + Updated: tappable.Updated, + } +} + +func BuildTappableResult(tappable *Tappable) ApiTappableResult { + return buildTappableResult(tappable) +} diff --git a/decoder/db_debug.go b/decoder/db_debug.go new file mode 100644 index 00000000..89498a3f --- /dev/null +++ b/decoder/db_debug.go @@ -0,0 +1,21 @@ +//go:build dbdebug + +package decoder + +import ( + "strings" + + log "github.com/sirupsen/logrus" +) + +// dbDebugEnabled is true when built with -tags dbdebug +const dbDebugEnabled = true + +// dbDebugLog logs a database operation with changed fields +func dbDebugLog(reason, entityType, id string, changedFields []string) { + fields := "" + if len(changedFields) > 0 { + fields = " changed=[" + strings.Join(changedFields, ", ") + "]" + } + log.Debugf("[DB_%s] %s id=%s%s", reason, entityType, id, fields) +} diff --git a/decoder/db_debug_off.go b/decoder/db_debug_off.go new file mode 100644 index 00000000..b5a32b77 --- /dev/null +++ b/decoder/db_debug_off.go @@ -0,0 +1,14 @@ +//go:build !dbdebug + +package decoder + +// dbDebugEnabled is false when dbdebug build tag is not set. +// The compiler will eliminate dead code in if statements checking this const. +const dbDebugEnabled = false + +// dbDebugLog is a no-op stub when dbdebug build tag is not set. +// This function is never called at runtime due to const-folding of dbDebugEnabled. +func dbDebugLog(reason, entityType, id string, changedFields []string) { + // No-op: this function exists only to satisfy the compiler. + // It will never be called because dbDebugEnabled is false. +} diff --git a/decoder/fort.go b/decoder/fort.go index bc308a01..4cc34417 100644 --- a/decoder/fort.go +++ b/decoder/fort.go @@ -27,6 +27,13 @@ type FortWebhook struct { Location Location `json:"location"` } +type FortChangeWebhook struct { + ChangeType string `json:"change_type"` + EditTypes []string `json:"edit_types,omitempty"` + Old *FortWebhook `json:"old,omitempty"` + New *FortWebhook `json:"new,omitempty"` +} + type FortChange string type FortType string @@ -90,59 +97,57 @@ func InitWebHookFortFromPokestop(stop *Pokestop) *FortWebhook { } func CreateFortWebhooks(ctx context.Context, dbDetails db.DbDetails, ids []string, fortType FortType, change FortChange) { - var gyms []Gym - var stops []Pokestop if fortType == GYM { for _, id := range ids { - gym, err := GetGymRecord(ctx, dbDetails, id) - if err != nil { - continue - } - if gym == nil { + gym, unlock, err := GetGymRecordReadOnly(ctx, dbDetails, id) + if err != nil || gym == nil { + if unlock != nil { + unlock() + } continue } - gyms = append(gyms, *gym) + + fort := InitWebHookFortFromGym(gym) + unlock() + + CreateFortWebHooks(fort, &FortWebhook{}, change) } } if fortType == POKESTOP { for _, id := range ids { - stop, err := GetPokestopRecord(ctx, dbDetails, id) - if err != nil { + stop, unlock, err := getPokestopRecordReadOnly(ctx, dbDetails, id) + if err != nil || stop == nil { + if unlock != nil { + unlock() + } continue } - if stop == nil { - continue - } - stops = append(stops, *stop) + + fort := InitWebHookFortFromPokestop(stop) + unlock() + + CreateFortWebHooks(fort, &FortWebhook{}, change) } } - for _, gym := range gyms { - fort := InitWebHookFortFromGym(&gym) - CreateFortWebHooks(fort, &FortWebhook{}, change) - } - for _, stop := range stops { - fort := InitWebHookFortFromPokestop(&stop) - CreateFortWebHooks(fort, &FortWebhook{}, change) - } } func CreateFortWebHooks(old *FortWebhook, new *FortWebhook, change FortChange) { if change == NEW { areas := MatchStatsGeofence(new.Location.Latitude, new.Location.Longitude) - hook := map[string]interface{}{ - "change_type": change.String(), - "new": new, + hook := FortChangeWebhook{ + ChangeType: change.String(), + New: new, } webhooksSender.AddMessage(webhooks.FortUpdate, hook, areas) statsCollector.UpdateFortCount(areas, new.Type, "addition") } else if change == REMOVAL { areas := MatchStatsGeofence(old.Location.Latitude, old.Location.Longitude) - hook := map[string]interface{}{ - "change_type": change.String(), - "old": old, + hook := FortChangeWebhook{ + ChangeType: change.String(), + Old: old, } webhooksSender.AddMessage(webhooks.FortUpdate, hook, areas) - statsCollector.UpdateFortCount(areas, new.Type, "removal") + statsCollector.UpdateFortCount(areas, old.Type, "removal") } else if change == EDIT { areas := MatchStatsGeofence(new.Location.Latitude, new.Location.Longitude) var editTypes []string @@ -181,11 +186,11 @@ func CreateFortWebHooks(old *FortWebhook, new *FortWebhook, change FortChange) { editTypes = append(editTypes, "location") } if len(editTypes) > 0 { - hook := map[string]interface{}{ - "change_type": change.String(), - "edit_types": editTypes, - "old": old, - "new": new, + hook := FortChangeWebhook{ + ChangeType: change.String(), + EditTypes: editTypes, + Old: old, + New: new, } webhooksSender.AddMessage(webhooks.FortUpdate, hook, areas) statsCollector.UpdateFortCount(areas, new.Type, "edit") @@ -217,55 +222,55 @@ func UpdateFortRecordWithGetMapFortsOutProto(ctx context.Context, db db.DbDetail // copySharedFieldsFrom copies shared fields from a pokestop to a gym during conversion func (gym *Gym) copySharedFieldsFrom(pokestop *Pokestop) { if pokestop.Name.Valid && !gym.Name.Valid { - gym.Name = pokestop.Name + gym.SetName(pokestop.Name) } if pokestop.Url.Valid && !gym.Url.Valid { - gym.Url = pokestop.Url + gym.SetUrl(pokestop.Url) } if pokestop.Description.Valid && !gym.Description.Valid { - gym.Description = pokestop.Description + gym.SetDescription(pokestop.Description) } if pokestop.PartnerId.Valid && !gym.PartnerId.Valid { - gym.PartnerId = pokestop.PartnerId + gym.SetPartnerId(pokestop.PartnerId) } if pokestop.ArScanEligible.Valid && !gym.ArScanEligible.Valid { - gym.ArScanEligible = pokestop.ArScanEligible + gym.SetArScanEligible(pokestop.ArScanEligible) } if pokestop.PowerUpLevel.Valid && !gym.PowerUpLevel.Valid { - gym.PowerUpLevel = pokestop.PowerUpLevel + gym.SetPowerUpLevel(pokestop.PowerUpLevel) } if pokestop.PowerUpPoints.Valid && !gym.PowerUpPoints.Valid { - gym.PowerUpPoints = pokestop.PowerUpPoints + gym.SetPowerUpPoints(pokestop.PowerUpPoints) } if pokestop.PowerUpEndTimestamp.Valid && !gym.PowerUpEndTimestamp.Valid { - gym.PowerUpEndTimestamp = pokestop.PowerUpEndTimestamp + gym.SetPowerUpEndTimestamp(pokestop.PowerUpEndTimestamp) } } // copySharedFieldsFrom copies shared fields from a gym to a pokestop during conversion func (stop *Pokestop) copySharedFieldsFrom(gym *Gym) { if gym.Name.Valid && !stop.Name.Valid { - stop.Name = gym.Name + stop.SetName(gym.Name) } if gym.Url.Valid && !stop.Url.Valid { - stop.Url = gym.Url + stop.SetUrl(gym.Url) } if gym.Description.Valid && !stop.Description.Valid { - stop.Description = gym.Description + stop.SetDescription(gym.Description) } if gym.PartnerId.Valid && !stop.PartnerId.Valid { - stop.PartnerId = gym.PartnerId + stop.SetPartnerId(gym.PartnerId) } if gym.ArScanEligible.Valid && !stop.ArScanEligible.Valid { - stop.ArScanEligible = gym.ArScanEligible + stop.SetArScanEligible(gym.ArScanEligible) } if gym.PowerUpLevel.Valid && !stop.PowerUpLevel.Valid { - stop.PowerUpLevel = gym.PowerUpLevel + stop.SetPowerUpLevel(gym.PowerUpLevel) } if gym.PowerUpPoints.Valid && !stop.PowerUpPoints.Valid { - stop.PowerUpPoints = gym.PowerUpPoints + stop.SetPowerUpPoints(gym.PowerUpPoints) } if gym.PowerUpEndTimestamp.Valid && !stop.PowerUpEndTimestamp.Valid { - stop.PowerUpEndTimestamp = gym.PowerUpEndTimestamp + stop.SetPowerUpEndTimestamp(gym.PowerUpEndTimestamp) } } diff --git a/decoder/fortRtree.go b/decoder/fortRtree.go index ce949d00..f914d73c 100644 --- a/decoder/fortRtree.go +++ b/decoder/fortRtree.go @@ -46,7 +46,10 @@ func LoadAllPokestops(details db.DbDetails) { if err != nil { log.Fatalln(err) } - GetPokestopRecord(context.Background(), details, place.Id) + _, unlock, _ := getPokestopRecordReadOnly(context.Background(), details, place.Id) + if unlock != nil { + unlock() + } } log.Infof("Loaded %d pokestops [finished]", count) } @@ -68,7 +71,10 @@ func LoadAllGyms(details db.DbDetails) { if err != nil { log.Fatalln(err) } - GetGymRecord(context.Background(), details, place.Id) + _, unlock, _ := GetGymRecordReadOnly(context.Background(), details, place.Id) + if unlock != nil { + unlock() + } } log.Infof("Loaded %d gyms [finished]", count) } diff --git a/decoder/fort_tracker.go b/decoder/fort_tracker.go index d9839e67..b3a2ae0d 100644 --- a/decoder/fort_tracker.go +++ b/decoder/fort_tracker.go @@ -409,11 +409,13 @@ func GetFortTracker() *FortTracker { return fortTracker } -// clearGymWithLock marks a gym as deleted while holding the striped mutex +// clearGymWithLock marks a gym as deleted while holding the object-level mutex func clearGymWithLock(ctx context.Context, dbDetails db.DbDetails, gymId string, cellId uint64, removeFromTracker bool) { - gymMutex, _ := gymStripedMutex.GetLock(gymId) - gymMutex.Lock() - defer gymMutex.Unlock() + // Lock the gym if it exists in cache + gym, unlock, _ := PeekGymRecord(gymId) + if gym != nil { + defer unlock() + } gymCache.Delete(gymId) if err := db.ClearOldGyms(ctx, dbDetails, []string{gymId}); err != nil { @@ -431,11 +433,13 @@ func clearGymWithLock(ctx context.Context, dbDetails db.DbDetails, gymId string, } } -// clearPokestopWithLock marks a pokestop as deleted while holding the striped mutex +// clearPokestopWithLock marks a pokestop as deleted while holding the object-level mutex func clearPokestopWithLock(ctx context.Context, dbDetails db.DbDetails, stopId string, cellId uint64, removeFromTracker bool) { - pokestopMutex, _ := pokestopStripedMutex.GetLock(stopId) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() + // Lock the pokestop if it exists in cache + pokestop, unlock, _ := PeekPokestopRecord(stopId) + if pokestop != nil { + defer unlock() + } pokestopCache.Delete(stopId) if err := db.ClearOldPokestops(ctx, dbDetails, []string{stopId}); err != nil { diff --git a/decoder/gmo_decode.go b/decoder/gmo_decode.go new file mode 100644 index 00000000..8f2a7f54 --- /dev/null +++ b/decoder/gmo_decode.go @@ -0,0 +1,226 @@ +package decoder + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func UpdateFortBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, p []RawFortData) { + // Logic is: + // 1. Filter out pokestops that are unchanged (last modified time) + // 2. Fetch current stops from database + // 3. Generate batch of inserts as needed (with on duplicate saveGymRecord) + + //var stopsToModify []string + + for _, fort := range p { + fortId := fort.Data.FortId + if fort.Data.FortType == pogo.FortType_CHECKPOINT && scanParameters.ProcessPokestops { + pokestop, unlock, err := getOrCreatePokestopRecord(ctx, db, fortId) + if err != nil { + log.Errorf("getOrCreatePokestopRecord: %s", err) + continue + } + + pokestop.updatePokestopFromFort(fort.Data, fort.Cell, fort.Timestamp/1000) + + // If this is a new pokestop, check if it was converted from a gym and copy shared fields + if pokestop.IsNewRecord() { + gym, gymUnlock, _ := GetGymRecordReadOnly(ctx, db, fortId) + if gym != nil { + pokestop.copySharedFieldsFrom(gym) + gymUnlock() + } + } + + savePokestopRecord(ctx, db, pokestop) + unlock() + + incidents := fort.Data.PokestopDisplays + if incidents == nil && fort.Data.PokestopDisplay != nil { + incidents = []*pogo.PokestopIncidentDisplayProto{fort.Data.PokestopDisplay} + } + + if incidents != nil { + for _, incidentProto := range incidents { + incident, unlock, err := getOrCreateIncidentRecord(ctx, db, incidentProto.IncidentId, fortId) + if err != nil { + log.Errorf("getOrCreateIncidentRecord: %s", err) + continue + } + incident.updateFromPokestopIncidentDisplay(incidentProto) + saveIncidentRecord(ctx, db, incident) + unlock() + } + } + } + + if fort.Data.FortType == pogo.FortType_GYM && scanParameters.ProcessGyms { + gym, gymUnlock, err := getOrCreateGymRecord(ctx, db, fortId) + if err != nil { + log.Errorf("getOrCreateGymRecord: %s", err) + continue + } + + gym.updateGymFromFort(fort.Data, fort.Cell) + + // If this is a new gym, check if it was converted from a pokestop and copy shared fields + if gym.IsNewRecord() { + pokestop, unlock, _ := getPokestopRecordReadOnly(ctx, db, fortId) + if pokestop != nil { + gym.copySharedFieldsFrom(pokestop) + unlock() + } + } + + saveGymRecord(ctx, db, gym) + gymUnlock() + } + } +} + +func UpdateStationBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, p []RawStationData) { + for _, stationProto := range p { + stationId := stationProto.Data.Id + station, unlock, err := getOrCreateStationRecord(ctx, db, stationId) + if err != nil { + log.Errorf("getOrCreateStationRecord: %s", err) + continue + } + station.updateFromStationProto(stationProto.Data, stationProto.Cell) + saveStationRecord(ctx, db, station) + unlock() + } +} + +func UpdatePokemonBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, wildPokemonList []RawWildPokemonData, nearbyPokemonList []RawNearbyPokemonData, mapPokemonList []RawMapPokemonData, weather []*pogo.ClientWeatherProto, username string) { + weatherLookup := make(map[int64]pogo.GameplayWeatherProto_WeatherCondition) + for _, weatherProto := range weather { + weatherLookup[weatherProto.S2CellId] = weatherProto.GameplayWeather.GameplayCondition + } + + for _, wild := range wildPokemonList { + encounterId := wild.Data.EncounterId + + // spawnpointUpdateFromWild doesn't need Pokemon lock + spawnpointUpdateFromWild(ctx, db, wild.Data, wild.Timestamp) + + if scanParameters.ProcessWild { + // Use read-only getter - we're only checking if update is needed, then queuing + pokemon, unlock, err := getPokemonRecordReadOnly(ctx, db, encounterId) + if err != nil { + log.Errorf("getPokemonRecordReadOnly: %s", err) + continue + } + + updateTime := wild.Timestamp / 1000 + shouldQueue := pokemon == nil || pokemon.wildSignificantUpdate(wild.Data, updateTime) + + if unlock != nil { + unlock() + } + + if shouldQueue { + // The sweeper will process it after timeout if no encounter arrives + pending := &PendingPokemon{ + EncounterId: encounterId, + WildPokemon: wild.Data, + CellId: int64(wild.Cell), + TimestampMs: wild.Timestamp, + UpdateTime: updateTime, + WeatherLookup: weatherLookup, + Username: username, + } + pokemonPendingQueue.AddPending(pending) + } + } + } + + if scanParameters.ProcessNearby { + for _, nearby := range nearbyPokemonList { + encounterId := nearby.Data.EncounterId + + if nearby.Data.FortId != "" || scanParameters.ProcessNearbyCell { + pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, encounterId) + if err != nil { + log.Printf("getOrCreatePokemonRecord: %s", err) + continue + } + + updateTime := nearby.Timestamp / 1000 + if pokemon.isNewRecord() || pokemon.nearbySignificantUpdate(nearby.Data, updateTime) { + pokemon.updateFromNearby(ctx, db, nearby.Data, int64(nearby.Cell), weatherLookup, nearby.Timestamp, username) + savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, nearby.Timestamp/1000) + } + + unlock() + } + } + } + + for _, mapPokemon := range mapPokemonList { + encounterId := mapPokemon.Data.EncounterId + + pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, encounterId) + if err != nil { + log.Printf("getOrCreatePokemonRecord: %s", err) + continue + } + + pokemon.updateFromMap(ctx, db, mapPokemon.Data, int64(mapPokemon.Cell), weatherLookup, mapPokemon.Timestamp, username) + storedDiskEncounter := diskEncounterCache.Get(encounterId) + if storedDiskEncounter != nil { + diskEncounter := storedDiskEncounter.Value() + diskEncounterCache.Delete(encounterId) + pokemon.updatePokemonFromDiskEncounterProto(ctx, db, diskEncounter, username) + //log.Infof("Processed stored disk encounter") + } + savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, mapPokemon.Timestamp/1000) + + unlock() + } +} + +func UpdateClientWeatherBatch(ctx context.Context, db db.DbDetails, p []*pogo.ClientWeatherProto, timestampMs int64, account string) (updates []WeatherUpdate) { + hourKey := timestampMs / time.Hour.Milliseconds() + for _, weatherProto := range p { + weather, unlock, err := getOrCreateWeatherRecord(ctx, db, weatherProto.S2CellId) + if err != nil { + log.Printf("getOrCreateWeatherRecord: %s", err) + continue + } + + if weather.newRecord || timestampMs >= weather.UpdatedMs { + state := getWeatherConsensusState(weatherProto.S2CellId, hourKey) + if state != nil { + publish, publishProto := state.applyObservation(hourKey, account, weatherProto) + if publish { + if publishProto == nil { + publishProto = weatherProto + } + weather.UpdatedMs = timestampMs + weather.updateWeatherFromClientWeatherProto(publishProto) + saveWeatherRecord(ctx, db, weather) + if weather.oldValues.GameplayCondition != weather.GameplayCondition { + updates = append(updates, WeatherUpdate{ + S2CellId: publishProto.S2CellId, + NewWeather: int32(publishProto.GetGameplayWeather().GetGameplayCondition()), + }) + } + } + } + } + + unlock() + } + return updates +} + +func UpdateClientMapS2CellBatch(ctx context.Context, db db.DbDetails, cellIds []uint64) { + saveS2CellRecords(ctx, db, cellIds) +} diff --git a/decoder/gym.go b/decoder/gym.go index 4f1784fe..902cb15b 100644 --- a/decoder/gym.go +++ b/decoder/gym.go @@ -1,111 +1,121 @@ package decoder import ( - "cmp" - "context" - "database/sql" - "encoding/json" "fmt" - "slices" - "strings" - "time" + "sync" - "golbat/geo" - - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" - - "golbat/config" - "golbat/db" - "golbat/pogo" - "golbat/util" - "golbat/webhooks" + "github.com/guregu/null/v6" ) // Gym struct. // REMINDER! Keep hasChangesGym updated after making changes type Gym struct { - Id string `db:"id" json:"id"` - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - Name null.String `db:"name" json:"name"` - Url null.String `db:"url" json:"url"` - LastModifiedTimestamp null.Int `db:"last_modified_timestamp" json:"last_modified_timestamp"` - RaidEndTimestamp null.Int `db:"raid_end_timestamp" json:"raid_end_timestamp"` - RaidSpawnTimestamp null.Int `db:"raid_spawn_timestamp" json:"raid_spawn_timestamp"` - RaidBattleTimestamp null.Int `db:"raid_battle_timestamp" json:"raid_battle_timestamp"` - Updated int64 `db:"updated" json:"updated"` - RaidPokemonId null.Int `db:"raid_pokemon_id" json:"raid_pokemon_id"` - GuardingPokemonId null.Int `db:"guarding_pokemon_id" json:"guarding_pokemon_id"` - GuardingPokemonDisplay null.String `db:"guarding_pokemon_display" json:"guarding_pokemon_display"` - AvailableSlots null.Int `db:"available_slots" json:"available_slots"` - TeamId null.Int `db:"team_id" json:"team_id"` - RaidLevel null.Int `db:"raid_level" json:"raid_level"` - Enabled null.Int `db:"enabled" json:"enabled"` - ExRaidEligible null.Int `db:"ex_raid_eligible" json:"ex_raid_eligible"` - InBattle null.Int `db:"in_battle" json:"in_battle"` - RaidPokemonMove1 null.Int `db:"raid_pokemon_move_1" json:"raid_pokemon_move_1"` - RaidPokemonMove2 null.Int `db:"raid_pokemon_move_2" json:"raid_pokemon_move_2"` - RaidPokemonForm null.Int `db:"raid_pokemon_form" json:"raid_pokemon_form"` - RaidPokemonAlignment null.Int `db:"raid_pokemon_alignment" json:"raid_pokemon_alignment"` - RaidPokemonCp null.Int `db:"raid_pokemon_cp" json:"raid_pokemon_cp"` - RaidIsExclusive null.Int `db:"raid_is_exclusive" json:"raid_is_exclusive"` - CellId null.Int `db:"cell_id" json:"cell_id"` - Deleted bool `db:"deleted" json:"deleted"` - TotalCp null.Int `db:"total_cp" json:"total_cp"` - FirstSeenTimestamp int64 `db:"first_seen_timestamp" json:"first_seen_timestamp"` - RaidPokemonGender null.Int `db:"raid_pokemon_gender" json:"raid_pokemon_gender"` - SponsorId null.Int `db:"sponsor_id" json:"sponsor_id"` - PartnerId null.String `db:"partner_id" json:"partner_id"` - RaidPokemonCostume null.Int `db:"raid_pokemon_costume" json:"raid_pokemon_costume"` - RaidPokemonEvolution null.Int `db:"raid_pokemon_evolution" json:"raid_pokemon_evolution"` - ArScanEligible null.Int `db:"ar_scan_eligible" json:"ar_scan_eligible"` - PowerUpLevel null.Int `db:"power_up_level" json:"power_up_level"` - PowerUpPoints null.Int `db:"power_up_points" json:"power_up_points"` - PowerUpEndTimestamp null.Int `db:"power_up_end_timestamp" json:"power_up_end_timestamp"` - Description null.String `db:"description" json:"description"` - Defenders null.String `db:"defenders" json:"defenders"` - Rsvps null.String `db:"rsvps" json:"rsvps"` - //`id` varchar(35) NOT NULL, - //`lat` double(18,14) NOT NULL, - //`lon` double(18,14) NOT NULL, - //`name` varchar(128) DEFAULT NULL, - //`url` varchar(200) DEFAULT NULL, - //`last_modified_timestamp` int unsigned DEFAULT NULL, - //`raid_end_timestamp` int unsigned DEFAULT NULL, - //`raid_spawn_timestamp` int unsigned DEFAULT NULL, - //`raid_battle_timestamp` int unsigned DEFAULT NULL, - //`updated` int unsigned NOT NULL, - //`raid_pokemon_id` smallint unsigned DEFAULT NULL, - //`guarding_pokemon_id` smallint unsigned DEFAULT NULL, - //`available_slots` smallint unsigned DEFAULT NULL, - //`availble_slots` smallint unsigned GENERATED ALWAYS AS (`available_slots`) VIRTUAL, - //`team_id` tinyint unsigned DEFAULT NULL, - //`raid_level` tinyint unsigned DEFAULT NULL, - //`enabled` tinyint unsigned DEFAULT NULL, - //`ex_raid_eligible` tinyint unsigned DEFAULT NULL, - //`in_battle` tinyint unsigned DEFAULT NULL, - //`raid_pokemon_move_1` smallint unsigned DEFAULT NULL, - //`raid_pokemon_move_2` smallint unsigned DEFAULT NULL, - //`raid_pokemon_form` smallint unsigned DEFAULT NULL, - //`raid_pokemon_cp` int unsigned DEFAULT NULL, - //`raid_is_exclusive` tinyint unsigned DEFAULT NULL, - //`cell_id` bigint unsigned DEFAULT NULL, - //`deleted` tinyint unsigned NOT NULL DEFAULT '0', - //`total_cp` int unsigned DEFAULT NULL, - //`first_seen_timestamp` int unsigned NOT NULL, - //`raid_pokemon_gender` tinyint unsigned DEFAULT NULL, - //`sponsor_id` smallint unsigned DEFAULT NULL, - //`partner_id` varchar(35) DEFAULT NULL, - //`raid_pokemon_costume` smallint unsigned DEFAULT NULL, - //`raid_pokemon_evolution` tinyint unsigned DEFAULT NULL, - //`ar_scan_eligible` tinyint unsigned DEFAULT NULL, - //`power_up_level` smallint unsigned DEFAULT NULL, - //`power_up_points` int unsigned DEFAULT NULL, - //`power_up_end_timestamp` int unsigned DEFAULT NULL, + mu sync.Mutex `db:"-"` // Object-level mutex + + Id string `db:"id"` + Lat float64 `db:"lat"` + Lon float64 `db:"lon"` + Name null.String `db:"name"` + Url null.String `db:"url"` + LastModifiedTimestamp null.Int `db:"last_modified_timestamp"` + RaidEndTimestamp null.Int `db:"raid_end_timestamp"` + RaidSpawnTimestamp null.Int `db:"raid_spawn_timestamp"` + RaidBattleTimestamp null.Int `db:"raid_battle_timestamp"` + Updated int64 `db:"updated"` + RaidPokemonId null.Int `db:"raid_pokemon_id"` + GuardingPokemonId null.Int `db:"guarding_pokemon_id"` + GuardingPokemonDisplay null.String `db:"guarding_pokemon_display"` + AvailableSlots null.Int `db:"available_slots"` + TeamId null.Int `db:"team_id"` + RaidLevel null.Int `db:"raid_level"` + Enabled null.Int `db:"enabled"` + ExRaidEligible null.Int `db:"ex_raid_eligible"` + InBattle null.Int `db:"in_battle"` + RaidPokemonMove1 null.Int `db:"raid_pokemon_move_1"` + RaidPokemonMove2 null.Int `db:"raid_pokemon_move_2"` + RaidPokemonForm null.Int `db:"raid_pokemon_form"` + RaidPokemonAlignment null.Int `db:"raid_pokemon_alignment"` + RaidPokemonCp null.Int `db:"raid_pokemon_cp"` + RaidIsExclusive null.Int `db:"raid_is_exclusive"` + CellId null.Int `db:"cell_id"` + Deleted bool `db:"deleted"` + TotalCp null.Int `db:"total_cp"` + FirstSeenTimestamp int64 `db:"first_seen_timestamp"` + RaidPokemonGender null.Int `db:"raid_pokemon_gender"` + SponsorId null.Int `db:"sponsor_id"` + PartnerId null.String `db:"partner_id"` + RaidPokemonCostume null.Int `db:"raid_pokemon_costume"` + RaidPokemonEvolution null.Int `db:"raid_pokemon_evolution"` + ArScanEligible null.Int `db:"ar_scan_eligible"` + PowerUpLevel null.Int `db:"power_up_level"` + PowerUpPoints null.Int `db:"power_up_points"` + PowerUpEndTimestamp null.Int `db:"power_up_end_timestamp"` + Description null.String `db:"description"` + Defenders null.String `db:"defenders"` + Rsvps null.String `db:"rsvps"` + + dirty bool `db:"-"` // Not persisted - tracks if object needs saving (to db) + internalDirty bool `db:"-"` // Not persisted - tracks if object needs saving (in memory only) + newRecord bool `db:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues GymOldValues `db:"-"` // Old values for webhook comparison +} + +// GymOldValues holds old field values for webhook comparison (populated when loading from cache/DB) +type GymOldValues struct { + Name null.String + Url null.String + Description null.String + Lat float64 + Lon float64 + TeamId null.Int + AvailableSlots null.Int + RaidLevel null.Int + RaidPokemonId null.Int + RaidSpawnTimestamp null.Int + Rsvps null.String + InBattle null.Int } +//`id` varchar(35) NOT NULL, +//`lat` double(18,14) NOT NULL, +//`lon` double(18,14) NOT NULL, +//`name` varchar(128) DEFAULT NULL, +//`url` varchar(200) DEFAULT NULL, +//`last_modified_timestamp` int unsigned DEFAULT NULL, +//`raid_end_timestamp` int unsigned DEFAULT NULL, +//`raid_spawn_timestamp` int unsigned DEFAULT NULL, +//`raid_battle_timestamp` int unsigned DEFAULT NULL, +//`updated` int unsigned NOT NULL, +//`raid_pokemon_id` smallint unsigned DEFAULT NULL, +//`guarding_pokemon_id` smallint unsigned DEFAULT NULL, +//`available_slots` smallint unsigned DEFAULT NULL, +//`availble_slots` smallint unsigned GENERATED ALWAYS AS (`available_slots`) VIRTUAL, +//`team_id` tinyint unsigned DEFAULT NULL, +//`raid_level` tinyint unsigned DEFAULT NULL, +//`enabled` tinyint unsigned DEFAULT NULL, +//`ex_raid_eligible` tinyint unsigned DEFAULT NULL, +//`in_battle` tinyint unsigned DEFAULT NULL, +//`raid_pokemon_move_1` smallint unsigned DEFAULT NULL, +//`raid_pokemon_move_2` smallint unsigned DEFAULT NULL, +//`raid_pokemon_form` smallint unsigned DEFAULT NULL, +//`raid_pokemon_cp` int unsigned DEFAULT NULL, +//`raid_is_exclusive` tinyint unsigned DEFAULT NULL, +//`cell_id` bigint unsigned DEFAULT NULL, +//`deleted` tinyint unsigned NOT NULL DEFAULT '0', +//`total_cp` int unsigned DEFAULT NULL, +//`first_seen_timestamp` int unsigned NOT NULL, +//`raid_pokemon_gender` tinyint unsigned DEFAULT NULL, +//`sponsor_id` smallint unsigned DEFAULT NULL, +//`partner_id` varchar(35) DEFAULT NULL, +//`raid_pokemon_costume` smallint unsigned DEFAULT NULL, +//`raid_pokemon_evolution` tinyint unsigned DEFAULT NULL, +//`ar_scan_eligible` tinyint unsigned DEFAULT NULL, +//`power_up_level` smallint unsigned DEFAULT NULL, +//`power_up_points` int unsigned DEFAULT NULL, +//`power_up_end_timestamp` int unsigned DEFAULT NULL, + // //SELECT CONCAT("'", GROUP_CONCAT(column_name ORDER BY ordinal_position SEPARATOR "', '"), "'") AS columns //FROM information_schema.columns @@ -115,720 +125,456 @@ type Gym struct { //FROM information_schema.columns //WHERE table_schema = 'db_name' AND table_name = 'tbl_name' -func GetGymRecord(ctx context.Context, db db.DbDetails, fortId string) (*Gym, error) { - inMemoryGym := gymCache.Get(fortId) - if inMemoryGym != nil { - gym := inMemoryGym.Value() - return &gym, nil - } - gym := Gym{} - err := db.GeneralDb.GetContext(ctx, &gym, "SELECT id, lat, lon, name, url, last_modified_timestamp, raid_end_timestamp, raid_spawn_timestamp, raid_battle_timestamp, updated, raid_pokemon_id, guarding_pokemon_id, guarding_pokemon_display, available_slots, team_id, raid_level, enabled, ex_raid_eligible, in_battle, raid_pokemon_move_1, raid_pokemon_move_2, raid_pokemon_form, raid_pokemon_alignment, raid_pokemon_cp, raid_is_exclusive, cell_id, deleted, total_cp, first_seen_timestamp, raid_pokemon_gender, sponsor_id, partner_id, raid_pokemon_costume, raid_pokemon_evolution, ar_scan_eligible, power_up_level, power_up_points, power_up_end_timestamp, description, defenders, rsvps FROM gym WHERE id = ?", fortId) - - statsCollector.IncDbQuery("select gym", err) - if err == sql.ErrNoRows { - return nil, nil - } - - if err != nil { - return nil, err - } - - gymCache.Set(fortId, gym, ttlcache.DefaultTTL) - if config.Config.TestFortInMemory { - fortRtreeUpdateGymOnGet(&gym) - } - return &gym, nil -} - -func escapeLike(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `%`, `\%`) - s = strings.ReplaceAll(s, `_`, `\_`) - return s -} - -func calculatePowerUpPoints(fortData *pogo.PokemonFortProto) (null.Int, null.Int) { - now := time.Now().Unix() - powerUpLevelExpirationMs := int64(fortData.PowerUpLevelExpirationMs) / 1000 - powerUpPoints := int64(fortData.PowerUpProgressPoints) - powerUpLevel := null.IntFrom(0) - powerUpEndTimestamp := null.NewInt(0, false) - if powerUpPoints < 50 { - powerUpLevel = null.IntFrom(0) - } else if powerUpPoints < 100 && powerUpLevelExpirationMs > now { - powerUpLevel = null.IntFrom(1) - powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) - } else if powerUpPoints < 150 && powerUpLevelExpirationMs > now { - powerUpLevel = null.IntFrom(2) - powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) - } else if powerUpLevelExpirationMs > now { - powerUpLevel = null.IntFrom(3) - powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) - } else { - powerUpLevel = null.IntFrom(0) - } - - return powerUpLevel, powerUpEndTimestamp -} - -func (gym *Gym) updateGymFromFort(fortData *pogo.PokemonFortProto, cellId uint64) *Gym { - type pokemonDisplay struct { - Form int `json:"form,omitempty"` - Costume int `json:"costume,omitempty"` - Gender int `json:"gender"` - Shiny bool `json:"shiny,omitempty"` - TempEvolution int `json:"temp_evolution,omitempty"` - TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` - Alignment int `json:"alignment,omitempty"` - Badge int `json:"badge,omitempty"` - Background *int64 `json:"background,omitempty"` - } - gym.Id = fortData.FortId - gym.Lat = fortData.Latitude //fmt.Sprintf("%f", fortData.Latitude) - gym.Lon = fortData.Longitude //fmt.Sprintf("%f", fortData.Longitude) - gym.Enabled = null.IntFrom(util.BoolToInt[int64](fortData.Enabled)) - gym.GuardingPokemonId = null.IntFrom(int64(fortData.GuardPokemonId)) - if fortData.GuardPokemonDisplay == nil { - gym.GuardingPokemonDisplay = null.NewString("", false) - } else { - display, _ := json.Marshal(pokemonDisplay{ - Form: int(fortData.GuardPokemonDisplay.Form), - Costume: int(fortData.GuardPokemonDisplay.Costume), - Gender: int(fortData.GuardPokemonDisplay.Gender), - Shiny: fortData.GuardPokemonDisplay.Shiny, - TempEvolution: int(fortData.GuardPokemonDisplay.CurrentTempEvolution), - TempEvolutionFinishMs: fortData.GuardPokemonDisplay.TemporaryEvolutionFinishMs, - Alignment: int(fortData.GuardPokemonDisplay.Alignment), - Badge: int(fortData.GuardPokemonDisplay.PokemonBadge), - Background: util.ExtractBackgroundFromDisplay(fortData.GuardPokemonDisplay), - }) - gym.GuardingPokemonDisplay = null.StringFrom(string(display)) - } - gym.TeamId = null.IntFrom(int64(fortData.Team)) - if fortData.GymDisplay != nil { - gym.AvailableSlots = null.IntFrom(int64(fortData.GymDisplay.SlotsAvailable)) - } else { - gym.AvailableSlots = null.IntFrom(6) // this may be an incorrect assumption - } - gym.LastModifiedTimestamp = null.IntFrom(fortData.LastModifiedMs / 1000) - gym.ExRaidEligible = null.IntFrom(util.BoolToInt[int64](fortData.IsExRaidEligible)) - - if fortData.ImageUrl != "" { - gym.Url = null.StringFrom(fortData.ImageUrl) - } - gym.InBattle = null.IntFrom(util.BoolToInt[int64](fortData.IsInBattle)) - gym.ArScanEligible = null.IntFrom(util.BoolToInt[int64](fortData.IsArScanEligible)) - gym.PowerUpPoints = null.IntFrom(int64(fortData.PowerUpProgressPoints)) - - gym.PowerUpLevel, gym.PowerUpEndTimestamp = calculatePowerUpPoints(fortData) - - if fortData.PartnerId == "" { - gym.PartnerId = null.NewString("", false) - } else { - gym.PartnerId = null.StringFrom(fortData.PartnerId) - } - - if fortData.ImageUrl != "" { - gym.Url = null.StringFrom(fortData.ImageUrl) - - } - if fortData.Team == 0 { // check!! - gym.TotalCp = null.IntFrom(0) - } else { - if fortData.GymDisplay != nil { - totalCp := int64(fortData.GymDisplay.TotalGymCp) - if gym.TotalCp.Int64-totalCp > 100 || totalCp-gym.TotalCp.Int64 > 100 { - gym.TotalCp = null.IntFrom(totalCp) - } - } else { - gym.TotalCp = null.IntFrom(0) - } - } - - if fortData.RaidInfo != nil { - gym.RaidEndTimestamp = null.IntFrom(int64(fortData.RaidInfo.RaidEndMs) / 1000) - gym.RaidSpawnTimestamp = null.IntFrom(int64(fortData.RaidInfo.RaidSpawnMs) / 1000) - raidBattleTimestamp := int64(fortData.RaidInfo.RaidBattleMs) / 1000 - - if gym.RaidBattleTimestamp.ValueOrZero() != raidBattleTimestamp { - // We are reporting a new raid, clear rsvp data - gym.Rsvps = null.NewString("", false) - } - gym.RaidBattleTimestamp = null.IntFrom(raidBattleTimestamp) - - gym.RaidLevel = null.IntFrom(int64(fortData.RaidInfo.RaidLevel)) - if fortData.RaidInfo.RaidPokemon != nil { - gym.RaidPokemonId = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonId)) - gym.RaidPokemonMove1 = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Move1)) - gym.RaidPokemonMove2 = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Move2)) - gym.RaidPokemonForm = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Form)) - gym.RaidPokemonAlignment = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Alignment)) - gym.RaidPokemonCp = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Cp)) - gym.RaidPokemonGender = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Gender)) - gym.RaidPokemonCostume = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Costume)) - gym.RaidPokemonEvolution = null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.CurrentTempEvolution)) - } else { - gym.RaidPokemonId = null.IntFrom(0) - gym.RaidPokemonMove1 = null.IntFrom(0) - gym.RaidPokemonMove2 = null.IntFrom(0) - gym.RaidPokemonForm = null.IntFrom(0) - gym.RaidPokemonAlignment = null.IntFrom(0) - gym.RaidPokemonCp = null.IntFrom(0) - gym.RaidPokemonGender = null.IntFrom(0) - gym.RaidPokemonCostume = null.IntFrom(0) - gym.RaidPokemonEvolution = null.IntFrom(0) - } - - gym.RaidIsExclusive = null.IntFrom(0) //null.IntFrom(util.BoolToInt[int64](fortData.RaidInfo.IsExclusive)) - } - - gym.CellId = null.IntFrom(int64(cellId)) - - if gym.Deleted { - gym.Deleted = false - log.Warnf("Cleared Gym with id '%s' is found again in GMO, therefore un-deleted", gym.Id) - // Restore in fort tracker if enabled - if fortTracker != nil { - fortTracker.RestoreFort(gym.Id, cellId, true, time.Now().Unix()) - } - } - - return gym -} - -func (gym *Gym) updateGymFromFortProto(fortData *pogo.FortDetailsOutProto) *Gym { - gym.Id = fortData.Id - gym.Lat = fortData.Latitude //fmt.Sprintf("%f", fortData.Latitude) - gym.Lon = fortData.Longitude //fmt.Sprintf("%f", fortData.Longitude) - if len(fortData.ImageUrl) > 0 { - gym.Url = null.StringFrom(fortData.ImageUrl[0]) +// IsDirty returns true if any field has been modified +func (gym *Gym) IsDirty() bool { + return gym.dirty +} + +// IsInternalDirty returns true if any field has been modified for in-memory +func (gym *Gym) IsInternalDirty() bool { + return gym.internalDirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (gym *Gym) ClearDirty() { + gym.dirty = false + gym.internalDirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (gym *Gym) IsNewRecord() bool { + return gym.newRecord +} + +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (gym *Gym) snapshotOldValues() { + gym.oldValues = GymOldValues{ + Name: gym.Name, + Url: gym.Url, + Description: gym.Description, + Lat: gym.Lat, + Lon: gym.Lon, + TeamId: gym.TeamId, + AvailableSlots: gym.AvailableSlots, + RaidLevel: gym.RaidLevel, + RaidPokemonId: gym.RaidPokemonId, + RaidSpawnTimestamp: gym.RaidSpawnTimestamp, + Rsvps: gym.Rsvps, + InBattle: gym.InBattle, } - gym.Name = null.StringFrom(fortData.Name) - - return gym -} +} + +// Lock acquires the Gym's mutex +func (gym *Gym) Lock() { + gym.mu.Lock() +} -func (gym *Gym) updateGymFromGymInfoOutProto(gymData *pogo.GymGetInfoOutProto) *Gym { - gym.Id = gymData.GymStatusAndDefenders.PokemonFortProto.FortId - gym.Lat = gymData.GymStatusAndDefenders.PokemonFortProto.Latitude - gym.Lon = gymData.GymStatusAndDefenders.PokemonFortProto.Longitude +// Unlock releases the Gym's mutex +func (gym *Gym) Unlock() { + gym.mu.Unlock() +} - // This will have gym defenders in it... - if len(gymData.Url) > 0 { - gym.Url = null.StringFrom(gymData.Url) - } - gym.Name = null.StringFrom(gymData.Name) +// --- Set methods with dirty tracking --- - if gymData.Description == "" { - gym.Description = null.NewString("", false) - } else { - gym.Description = null.StringFrom(gymData.Description) +func (gym *Gym) SetId(v string) { + if gym.Id != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Id:%s->%s", gym.Id, v)) + } + gym.Id = v + gym.dirty = true } +} - type pokemonGymDefender struct { - PokemonId int `json:"pokemon_id,omitempty"` - Form int `json:"form,omitempty"` - Costume int `json:"costume,omitempty"` - Gender int `json:"gender"` - Shiny bool `json:"shiny,omitempty"` - TempEvolution int `json:"temp_evolution,omitempty"` - TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` - Alignment int `json:"alignment,omitempty"` - Badge int `json:"badge,omitempty"` - Background *int64 `json:"background,omitempty"` - DeployedMs int64 `json:"deployed_ms,omitempty"` - DeployedTime int64 `json:"deployed_time,omitempty"` - BattlesWon int32 `json:"battles_won"` - BattlesLost int32 `json:"battles_lost"` - TimesFed int32 `json:"times_fed"` - MotivationNow util.RoundedFloat4 `json:"motivation_now"` - CpNow int32 `json:"cp_now"` - CpWhenDeployed int32 `json:"cp_when_deployed"` - } - - var defenders []pokemonGymDefender - now := time.Now() - for _, protoDefender := range gymData.GymStatusAndDefenders.GymDefender { - motivatedPokemon := protoDefender.MotivatedPokemon - pokemonDisplay := motivatedPokemon.Pokemon.PokemonDisplay - deploymentTotals := protoDefender.DeploymentTotals - defender := pokemonGymDefender{ - DeployedMs: protoDefender.DeploymentTotals.DeploymentDurationMs, - DeployedTime: now. - Add(-1 * time.Millisecond * time.Duration(deploymentTotals.DeploymentDurationMs)). - Unix(), // This will only be approximately correct - BattlesLost: deploymentTotals.BattlesLost, - BattlesWon: deploymentTotals.BattlesWon, - TimesFed: deploymentTotals.TimesFed, - PokemonId: int(protoDefender.MotivatedPokemon.Pokemon.PokemonId), - Form: int(pokemonDisplay.Form), - Costume: int(pokemonDisplay.Costume), - Gender: int(pokemonDisplay.Gender), - TempEvolution: int(pokemonDisplay.CurrentTempEvolution), - TempEvolutionFinishMs: pokemonDisplay.TemporaryEvolutionFinishMs, - Alignment: int(pokemonDisplay.Alignment), - Badge: int(pokemonDisplay.PokemonBadge), - Background: util.ExtractBackgroundFromDisplay(pokemonDisplay), - Shiny: pokemonDisplay.Shiny, - MotivationNow: util.RoundedFloat4(motivatedPokemon.MotivationNow), - CpNow: motivatedPokemon.CpNow, - CpWhenDeployed: motivatedPokemon.CpWhenDeployed, - } - defenders = append(defenders, defender) - } - bDefenders, _ := json.Marshal(defenders) - gym.Defenders = null.StringFrom(string(bDefenders)) - // log.Debugf("Gym %s defenders %s ", gym.Id, string(bDefenders)) - - return gym -} - -func (gym *Gym) updateGymFromGetMapFortsOutProto(fortData *pogo.GetMapFortsOutProto_FortProto, skipName bool) *Gym { - gym.Id = fortData.Id - gym.Lat = fortData.Latitude - gym.Lon = fortData.Longitude - - if len(fortData.Image) > 0 { - gym.Url = null.StringFrom(fortData.Image[0].Url) - } - if !skipName { - gym.Name = null.StringFrom(fortData.Name) - } - - if gym.Deleted { - log.Debugf("Cleared Gym with id '%s' is found again in GMF, therefore kept deleted", gym.Id) - } - - return gym -} - -func (gym *Gym) updateGymFromRsvpProto(fortData *pogo.GetEventRsvpsOutProto) *Gym { - type rsvpTimeslot struct { - Timeslot int64 `json:"timeslot"` - GoingCount int32 `json:"going_count"` - MaybeCount int32 `json:"maybe_count"` - } - - timeslots := make([]rsvpTimeslot, 0) - - for _, timeslot := range fortData.RsvpTimeslots { - if timeslot.GoingCount > 0 || timeslot.MaybeCount > 0 { - timeslots = append(timeslots, rsvpTimeslot{ - Timeslot: timeslot.TimeSlot, - GoingCount: timeslot.GoingCount, - MaybeCount: timeslot.MaybeCount, - }) - } - } - - if len(timeslots) == 0 { - gym.Rsvps = null.NewString("", false) - } else { - slices.SortFunc(timeslots, func(a, b rsvpTimeslot) int { - return cmp.Compare(a.Timeslot, b.Timeslot) - }) - - bRsvps, _ := json.Marshal(timeslots) - gym.Rsvps = null.StringFrom(string(bRsvps)) - } - - return gym -} - -// hasChangesGym compares two Gym structs -// Float tolerance: Lat, Lon -func hasChangesGym(old *Gym, new *Gym) bool { - return old.Id != new.Id || - old.Name != new.Name || - old.Url != new.Url || - old.LastModifiedTimestamp != new.LastModifiedTimestamp || - old.RaidEndTimestamp != new.RaidEndTimestamp || - old.RaidSpawnTimestamp != new.RaidSpawnTimestamp || - old.RaidBattleTimestamp != new.RaidBattleTimestamp || - old.Updated != new.Updated || - old.RaidPokemonId != new.RaidPokemonId || - old.GuardingPokemonId != new.GuardingPokemonId || - old.AvailableSlots != new.AvailableSlots || - old.TeamId != new.TeamId || - old.RaidLevel != new.RaidLevel || - old.Enabled != new.Enabled || - old.ExRaidEligible != new.ExRaidEligible || - // old.InBattle != new.InBattle || - old.RaidPokemonMove1 != new.RaidPokemonMove1 || - old.RaidPokemonMove2 != new.RaidPokemonMove2 || - old.RaidPokemonForm != new.RaidPokemonForm || - old.RaidPokemonAlignment != new.RaidPokemonAlignment || - old.RaidPokemonCp != new.RaidPokemonCp || - old.RaidIsExclusive != new.RaidIsExclusive || - old.CellId != new.CellId || - old.Deleted != new.Deleted || - old.TotalCp != new.TotalCp || - old.FirstSeenTimestamp != new.FirstSeenTimestamp || - old.RaidPokemonGender != new.RaidPokemonGender || - old.SponsorId != new.SponsorId || - old.PartnerId != new.PartnerId || - old.RaidPokemonCostume != new.RaidPokemonCostume || - old.RaidPokemonEvolution != new.RaidPokemonEvolution || - old.ArScanEligible != new.ArScanEligible || - old.PowerUpLevel != new.PowerUpLevel || - old.PowerUpPoints != new.PowerUpPoints || - old.PowerUpEndTimestamp != new.PowerUpEndTimestamp || - old.Description != new.Description || - old.Rsvps != new.Rsvps || - !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) - -} - -// hasChangesInternalGym compares two Gym structs for changes that will be stored in memory -// Float tolerance: Lat, Lon -func hasInternalChangesGym(old *Gym, new *Gym) bool { - return old.InBattle != new.InBattle || - old.Defenders != new.Defenders -} - -type GymDetailsWebhook struct { - Id string `json:"id"` - Name string `json:"name"` - Url string `json:"url"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Team int64 `json:"team"` - GuardPokemonId int64 `json:"guard_pokemon_id"` - SlotsAvailable int64 `json:"slots_available"` - ExRaidEligible int64 `json:"ex_raid_eligible"` - InBattle bool `json:"in_battle"` - SponsorId int64 `json:"sponsor_id"` - PartnerId int64 `json:"partner_id"` - PowerUpPoints int64 `json:"power_up_points"` - PowerUpLevel int64 `json:"power_up_level"` - PowerUpEndTimestamp int64 `json:"power_up_end_timestamp"` - ArScanEligible int64 `json:"ar_scan_eligible"` - Defenders any `json:"defenders"` - - //"id": id, - //"name": name ?? "Unknown", - //"url": url ?? "", - //"latitude": lat, - //"longitude": lon, - //"team": teamId ?? 0, - //"guard_pokemon_id": guardPokemonId ?? 0, - //"slots_available": availableSlots ?? 6, - //"ex_raid_eligible": exRaidEligible ?? 0, - //"in_battle": inBattle ?? false, - //"sponsor_id": sponsorId ?? 0, - //"partner_id": partnerId ?? 0, - //"power_up_points": powerUpPoints ?? 0, - //"power_up_level": powerUpLevel ?? 0, - //"power_up_end_timestamp": powerUpEndTimestamp ?? 0, - //"ar_scan_eligible": arScanEligible ?? 0 -} - -func createGymFortWebhooks(oldGym *Gym, gym *Gym) { - fort := InitWebHookFortFromGym(gym) - oldFort := InitWebHookFortFromGym(oldGym) - if oldGym == nil { - CreateFortWebHooks(oldFort, fort, NEW) - } else { - CreateFortWebHooks(oldFort, fort, EDIT) - } -} - -func createGymWebhooks(oldGym *Gym, gym *Gym, areas []geo.AreaName) { - if oldGym == nil || - (oldGym.AvailableSlots != gym.AvailableSlots || oldGym.TeamId != gym.TeamId || oldGym.InBattle != gym.InBattle) { - gymDetails := GymDetailsWebhook{ - Id: gym.Id, - Name: gym.Name.ValueOrZero(), - Url: gym.Url.ValueOrZero(), - Latitude: gym.Lat, - Longitude: gym.Lon, - Team: gym.TeamId.ValueOrZero(), - GuardPokemonId: gym.GuardingPokemonId.ValueOrZero(), - SlotsAvailable: func() int64 { - if gym.AvailableSlots.Valid { - return gym.AvailableSlots.Int64 - } else { - return 6 - } - }(), - ExRaidEligible: gym.ExRaidEligible.ValueOrZero(), - InBattle: func() bool { return gym.InBattle.ValueOrZero() != 0 }(), - Defenders: func() any { - if gym.Defenders.Valid { - return json.RawMessage(gym.Defenders.ValueOrZero()) - } else { - return nil - } - }(), - } - - webhooksSender.AddMessage(webhooks.GymDetails, gymDetails, areas) - } - - if gym.RaidSpawnTimestamp.ValueOrZero() > 0 && - (oldGym == nil || oldGym.RaidLevel != gym.RaidLevel || - oldGym.RaidPokemonId != gym.RaidPokemonId || - oldGym.RaidSpawnTimestamp != gym.RaidSpawnTimestamp || oldGym.Rsvps != gym.Rsvps) { - raidBattleTime := gym.RaidBattleTimestamp.ValueOrZero() - raidEndTime := gym.RaidEndTimestamp.ValueOrZero() - now := time.Now().Unix() - - if (raidBattleTime > now && gym.RaidLevel.ValueOrZero() > 0) || - (raidEndTime > now && gym.RaidPokemonId.ValueOrZero() > 0) { - raidHook := map[string]any{ - "gym_id": gym.Id, - "gym_name": func() string { - if !gym.Name.Valid { - return "Unknown" - } else { - return gym.Name.String - } - }(), - "gym_url": gym.Url.ValueOrZero(), - "latitude": gym.Lat, - "longitude": gym.Lon, - "team_id": gym.TeamId.ValueOrZero(), - "spawn": gym.RaidSpawnTimestamp.ValueOrZero(), - "start": gym.RaidBattleTimestamp.ValueOrZero(), - "end": gym.RaidEndTimestamp.ValueOrZero(), - "level": gym.RaidLevel.ValueOrZero(), - "pokemon_id": gym.RaidPokemonId.ValueOrZero(), - "cp": gym.RaidPokemonCp.ValueOrZero(), - "gender": gym.RaidPokemonGender.ValueOrZero(), - "form": gym.RaidPokemonForm.ValueOrZero(), - "alignment": gym.RaidPokemonAlignment.ValueOrZero(), - "costume": gym.RaidPokemonCostume.ValueOrZero(), - "evolution": gym.RaidPokemonEvolution.ValueOrZero(), - "move_1": gym.RaidPokemonMove1.ValueOrZero(), - "move_2": gym.RaidPokemonMove2.ValueOrZero(), - "ex_raid_eligible": gym.ExRaidEligible.ValueOrZero(), - "is_exclusive": gym.RaidIsExclusive.ValueOrZero(), - "sponsor_id": gym.SponsorId.ValueOrZero(), - "partner_id": gym.PartnerId.ValueOrZero(), - "power_up_points": gym.PowerUpPoints.ValueOrZero(), - "power_up_level": gym.PowerUpLevel.ValueOrZero(), - "power_up_end_timestamp": gym.PowerUpEndTimestamp.ValueOrZero(), - "ar_scan_eligible": gym.ArScanEligible.ValueOrZero(), - "rsvps": func() any { - if !gym.Rsvps.Valid { - return nil - } else { - return json.RawMessage(gym.Rsvps.ValueOrZero()) - } - }(), - } - - webhooksSender.AddMessage(webhooks.Raid, raidHook, areas) - statsCollector.UpdateRaidCount(areas, gym.RaidLevel.ValueOrZero()) - } - } -} - -func saveGymRecord(ctx context.Context, db db.DbDetails, gym *Gym) { - oldGym, _ := GetGymRecord(ctx, db, gym.Id) - - now := time.Now().Unix() - if oldGym != nil && !hasChangesGym(oldGym, gym) { - if oldGym.Updated > now-900 { - // if a gym is unchanged, and we are within 15 minutes don't make any changes - // however, gym battle toggle a chance to trigger a web hook and make sure we - // save defender changes to internal cache - - if hasInternalChangesGym(oldGym, gym) { - areas := MatchStatsGeofence(gym.Lat, gym.Lon) - createGymWebhooks(oldGym, gym, areas) - - gymCache.Set(gym.Id, *gym, ttlcache.DefaultTTL) - } - - return - } - } - - gym.Updated = now - - //log.Traceln(cmp.Diff(oldGym, gym)) - if oldGym == nil { - res, err := db.GeneralDb.NamedExecContext(ctx, "INSERT INTO gym (id,lat,lon,name,url,last_modified_timestamp,raid_end_timestamp,raid_spawn_timestamp,raid_battle_timestamp,updated,raid_pokemon_id,guarding_pokemon_id,guarding_pokemon_display,available_slots,team_id,raid_level,enabled,ex_raid_eligible,in_battle,raid_pokemon_move_1,raid_pokemon_move_2,raid_pokemon_form,raid_pokemon_alignment,raid_pokemon_cp,raid_is_exclusive,cell_id,deleted,total_cp,first_seen_timestamp,raid_pokemon_gender,sponsor_id,partner_id,raid_pokemon_costume,raid_pokemon_evolution,ar_scan_eligible,power_up_level,power_up_points,power_up_end_timestamp,description, defenders, rsvps) "+ - "VALUES (:id,:lat,:lon,:name,:url,UNIX_TIMESTAMP(),:raid_end_timestamp,:raid_spawn_timestamp,:raid_battle_timestamp,:updated,:raid_pokemon_id,:guarding_pokemon_id,:guarding_pokemon_display,:available_slots,:team_id,:raid_level,:enabled,:ex_raid_eligible,:in_battle,:raid_pokemon_move_1,:raid_pokemon_move_2,:raid_pokemon_form,:raid_pokemon_alignment,:raid_pokemon_cp,:raid_is_exclusive,:cell_id,0,:total_cp,UNIX_TIMESTAMP(),:raid_pokemon_gender,:sponsor_id,:partner_id,:raid_pokemon_costume,:raid_pokemon_evolution,:ar_scan_eligible,:power_up_level,:power_up_points,:power_up_end_timestamp,:description, :defenders, :rsvps)", gym) - - statsCollector.IncDbQuery("insert gym", err) - if err != nil { - log.Errorf("insert gym: %s", err) - return - } - - _, _ = res, err - } else { - res, err := db.GeneralDb.NamedExecContext(ctx, "UPDATE gym SET "+ - "lat = :lat, "+ - "lon = :lon, "+ - "name = :name, "+ - "url = :url, "+ - "last_modified_timestamp = :last_modified_timestamp, "+ - "raid_end_timestamp = :raid_end_timestamp, "+ - "raid_spawn_timestamp = :raid_spawn_timestamp, "+ - "raid_battle_timestamp = :raid_battle_timestamp, "+ - "updated = :updated, "+ - "raid_pokemon_id = :raid_pokemon_id, "+ - "guarding_pokemon_id = :guarding_pokemon_id, "+ - "guarding_pokemon_display = :guarding_pokemon_display, "+ - "available_slots = :available_slots, "+ - "team_id = :team_id, "+ - "raid_level = :raid_level, "+ - "enabled = :enabled, "+ - "ex_raid_eligible = :ex_raid_eligible, "+ - "in_battle = :in_battle, "+ - "raid_pokemon_move_1 = :raid_pokemon_move_1, "+ - "raid_pokemon_move_2 = :raid_pokemon_move_2, "+ - "raid_pokemon_form = :raid_pokemon_form, "+ - "raid_pokemon_alignment = :raid_pokemon_alignment, "+ - "raid_pokemon_cp = :raid_pokemon_cp, "+ - "raid_is_exclusive = :raid_is_exclusive, "+ - "cell_id = :cell_id, "+ - "deleted = :deleted, "+ - "total_cp = :total_cp, "+ - "raid_pokemon_gender = :raid_pokemon_gender, "+ - "sponsor_id = :sponsor_id, "+ - "partner_id = :partner_id, "+ - "raid_pokemon_costume = :raid_pokemon_costume, "+ - "raid_pokemon_evolution = :raid_pokemon_evolution, "+ - "ar_scan_eligible = :ar_scan_eligible, "+ - "power_up_level = :power_up_level, "+ - "power_up_points = :power_up_points, "+ - "power_up_end_timestamp = :power_up_end_timestamp,"+ - "description = :description,"+ - "defenders = :defenders,"+ - "rsvps = :rsvps "+ - "WHERE id = :id", gym, - ) - statsCollector.IncDbQuery("update gym", err) - if err != nil { - log.Errorf("Update gym %s", err) +func (gym *Gym) SetLat(v float64) { + if !floatAlmostEqual(gym.Lat, v, floatTolerance) { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Lat:%f->%f", gym.Lat, v)) } - _, _ = res, err + gym.Lat = v + gym.dirty = true } +} - gymCache.Set(gym.Id, *gym, ttlcache.DefaultTTL) - areas := MatchStatsGeofence(gym.Lat, gym.Lon) - createGymWebhooks(oldGym, gym, areas) - createGymFortWebhooks(oldGym, gym) - updateRaidStats(oldGym, gym, areas) +func (gym *Gym) SetLon(v float64) { + if !floatAlmostEqual(gym.Lon, v, floatTolerance) { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Lon:%f->%f", gym.Lon, v)) + } + gym.Lon = v + gym.dirty = true + } } -func updateGymGetMapFortCache(gym *Gym, skipName bool) { - storedGetMapFort := getMapFortsCache.Get(gym.Id) - if storedGetMapFort != nil { - getMapFort := storedGetMapFort.Value() - getMapFortsCache.Delete(gym.Id) - gym.updateGymFromGetMapFortsOutProto(getMapFort, skipName) - log.Debugf("Updated Gym using stored getMapFort: %s", gym.Id) +func (gym *Gym) SetName(v null.String) { + if gym.Name != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Name:%s->%s", FormatNull(gym.Name), FormatNull(v))) + } + gym.Name = v + gym.dirty = true } } -func UpdateGymRecordWithFortDetailsOutProto(ctx context.Context, db db.DbDetails, fort *pogo.FortDetailsOutProto) string { - gymMutex, _ := gymStripedMutex.GetLock(fort.Id) - gymMutex.Lock() - defer gymMutex.Unlock() +func (gym *Gym) SetUrl(v null.String) { + if gym.Url != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Url:%s->%s", FormatNull(gym.Url), FormatNull(v))) + } + gym.Url = v + gym.dirty = true + } +} - gym, err := GetGymRecord(ctx, db, fort.Id) // should check error - if err != nil { - return err.Error() +func (gym *Gym) SetLastModifiedTimestamp(v null.Int) { + if gym.LastModifiedTimestamp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("LastModifiedTimestamp:%s->%s", FormatNull(gym.LastModifiedTimestamp), FormatNull(v))) + } + gym.LastModifiedTimestamp = v + gym.dirty = true } +} - if gym == nil { - gym = &Gym{} +func (gym *Gym) SetRaidEndTimestamp(v null.Int) { + if gym.RaidEndTimestamp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidEndTimestamp:%s->%s", FormatNull(gym.RaidEndTimestamp), FormatNull(v))) + } + gym.RaidEndTimestamp = v + gym.dirty = true } - gym.updateGymFromFortProto(fort) +} - updateGymGetMapFortCache(gym, true) - saveGymRecord(ctx, db, gym) +func (gym *Gym) SetRaidSpawnTimestamp(v null.Int) { + if gym.RaidSpawnTimestamp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidSpawnTimestamp:%s->%s", FormatNull(gym.RaidSpawnTimestamp), FormatNull(v))) + } + gym.RaidSpawnTimestamp = v + gym.dirty = true + } +} - return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +func (gym *Gym) SetRaidBattleTimestamp(v null.Int) { + if gym.RaidBattleTimestamp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidBattleTimestamp:%s->%s", FormatNull(gym.RaidBattleTimestamp), FormatNull(v))) + } + gym.RaidBattleTimestamp = v + gym.dirty = true + } } -func UpdateGymRecordWithGymInfoProto(ctx context.Context, db db.DbDetails, gymInfo *pogo.GymGetInfoOutProto) string { - gymMutex, _ := gymStripedMutex.GetLock(gymInfo.GymStatusAndDefenders.PokemonFortProto.FortId) - gymMutex.Lock() - defer gymMutex.Unlock() +func (gym *Gym) SetRaidPokemonId(v null.Int) { + if gym.RaidPokemonId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonId:%s->%s", FormatNull(gym.RaidPokemonId), FormatNull(v))) + } + gym.RaidPokemonId = v + gym.dirty = true + } +} - gym, err := GetGymRecord(ctx, db, gymInfo.GymStatusAndDefenders.PokemonFortProto.FortId) // should check error - if err != nil { - return err.Error() +func (gym *Gym) SetGuardingPokemonId(v null.Int) { + if gym.GuardingPokemonId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("GuardingPokemonId:%s->%s", FormatNull(gym.GuardingPokemonId), FormatNull(v))) + } + gym.GuardingPokemonId = v + gym.dirty = true } +} - if gym == nil { - gym = &Gym{} +func (gym *Gym) SetGuardingPokemonDisplay(v null.String) { + if gym.GuardingPokemonDisplay != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("GuardingPokemonDisplay:%s->%s", FormatNull(gym.GuardingPokemonDisplay), FormatNull(v))) + } + gym.GuardingPokemonDisplay = v + gym.dirty = true } - gym.updateGymFromGymInfoOutProto(gymInfo) +} - updateGymGetMapFortCache(gym, true) - saveGymRecord(ctx, db, gym) - return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +func (gym *Gym) SetAvailableSlots(v null.Int) { + if gym.AvailableSlots != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("AvailableSlots:%s->%s", FormatNull(gym.AvailableSlots), FormatNull(v))) + } + gym.AvailableSlots = v + gym.dirty = true + } } -func UpdateGymRecordWithGetMapFortsOutProto(ctx context.Context, db db.DbDetails, mapFort *pogo.GetMapFortsOutProto_FortProto) (bool, string) { - gymMutex, _ := gymStripedMutex.GetLock(mapFort.Id) - gymMutex.Lock() - defer gymMutex.Unlock() +func (gym *Gym) SetTeamId(v null.Int) { + if gym.TeamId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("TeamId:%s->%s", FormatNull(gym.TeamId), FormatNull(v))) + } + gym.TeamId = v + gym.dirty = true + } +} - gym, err := GetGymRecord(ctx, db, mapFort.Id) - if err != nil { - return false, err.Error() +func (gym *Gym) SetRaidLevel(v null.Int) { + if gym.RaidLevel != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidLevel:%s->%s", FormatNull(gym.RaidLevel), FormatNull(v))) + } + gym.RaidLevel = v + gym.dirty = true } +} - // we missed it in Pokestop & Gym. Lets save it to cache - if gym == nil { - return false, "" +func (gym *Gym) SetEnabled(v null.Int) { + if gym.Enabled != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Enabled:%s->%s", FormatNull(gym.Enabled), FormatNull(v))) + } + gym.Enabled = v + gym.dirty = true } +} - gym.updateGymFromGetMapFortsOutProto(mapFort, false) - saveGymRecord(ctx, db, gym) - return true, fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +func (gym *Gym) SetExRaidEligible(v null.Int) { + if gym.ExRaidEligible != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("ExRaidEligible:%s->%s", FormatNull(gym.ExRaidEligible), FormatNull(v))) + } + gym.ExRaidEligible = v + gym.dirty = true + } } -func UpdateGymRecordWithRsvpProto(ctx context.Context, db db.DbDetails, req *pogo.RaidDetails, resp *pogo.GetEventRsvpsOutProto) string { - gymMutex, _ := gymStripedMutex.GetLock(req.FortId) - gymMutex.Lock() - defer gymMutex.Unlock() +func (gym *Gym) SetInBattle(v null.Int) { + if gym.InBattle != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("InBattle:%s->%s", FormatNull(gym.InBattle), FormatNull(v))) + } + gym.InBattle = v + //Do not set to dirty, as don't trigger an update + gym.internalDirty = true + } +} - gym, err := GetGymRecord(ctx, db, req.FortId) - if err != nil { - return err.Error() +func (gym *Gym) SetRaidPokemonMove1(v null.Int) { + if gym.RaidPokemonMove1 != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonMove1:%s->%s", FormatNull(gym.RaidPokemonMove1), FormatNull(v))) + } + gym.RaidPokemonMove1 = v + gym.dirty = true } +} - if gym == nil { - // Do not add RSVP details to unknown gyms - return fmt.Sprintf("%s Gym not present", req.FortId) +func (gym *Gym) SetRaidPokemonMove2(v null.Int) { + if gym.RaidPokemonMove2 != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonMove2:%s->%s", FormatNull(gym.RaidPokemonMove2), FormatNull(v))) + } + gym.RaidPokemonMove2 = v + gym.dirty = true } - gym.updateGymFromRsvpProto(resp) +} - saveGymRecord(ctx, db, gym) +func (gym *Gym) SetRaidPokemonForm(v null.Int) { + if gym.RaidPokemonForm != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonForm:%s->%s", FormatNull(gym.RaidPokemonForm), FormatNull(v))) + } + gym.RaidPokemonForm = v + gym.dirty = true + } +} - return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +func (gym *Gym) SetRaidPokemonAlignment(v null.Int) { + if gym.RaidPokemonAlignment != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonAlignment:%s->%s", FormatNull(gym.RaidPokemonAlignment), FormatNull(v))) + } + gym.RaidPokemonAlignment = v + gym.dirty = true + } +} + +func (gym *Gym) SetRaidPokemonCp(v null.Int) { + if gym.RaidPokemonCp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonCp:%s->%s", FormatNull(gym.RaidPokemonCp), FormatNull(v))) + } + gym.RaidPokemonCp = v + gym.dirty = true + } +} + +func (gym *Gym) SetRaidIsExclusive(v null.Int) { + if gym.RaidIsExclusive != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidIsExclusive:%s->%s", FormatNull(gym.RaidIsExclusive), FormatNull(v))) + } + gym.RaidIsExclusive = v + gym.dirty = true + } +} + +func (gym *Gym) SetCellId(v null.Int) { + if gym.CellId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("CellId:%s->%s", FormatNull(gym.CellId), FormatNull(v))) + } + gym.CellId = v + gym.dirty = true + } +} + +func (gym *Gym) SetDeleted(v bool) { + if gym.Deleted != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Deleted:%t->%t", gym.Deleted, v)) + } + gym.Deleted = v + gym.dirty = true + } } -func ClearGymRsvp(ctx context.Context, db db.DbDetails, fortId string) string { - gymMutex, _ := gymStripedMutex.GetLock(fortId) - gymMutex.Lock() - defer gymMutex.Unlock() +func (gym *Gym) SetTotalCp(v null.Int) { + if gym.TotalCp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("TotalCp:%s->%s", FormatNull(gym.TotalCp), FormatNull(v))) + } + gym.TotalCp = v + gym.dirty = true + } +} - gym, err := GetGymRecord(ctx, db, fortId) - if err != nil { - return err.Error() +func (gym *Gym) SetRaidPokemonGender(v null.Int) { + if gym.RaidPokemonGender != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonGender:%s->%s", FormatNull(gym.RaidPokemonGender), FormatNull(v))) + } + gym.RaidPokemonGender = v + gym.dirty = true } +} - if gym == nil { - // Do not add RSVP details to unknown gyms - return fmt.Sprintf("%s Gym not present", fortId) +func (gym *Gym) SetSponsorId(v null.Int) { + if gym.SponsorId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("SponsorId:%s->%s", FormatNull(gym.SponsorId), FormatNull(v))) + } + gym.SponsorId = v + gym.dirty = true } +} - if gym.Rsvps.Valid { - gym.Rsvps = null.NewString("", false) +func (gym *Gym) SetPartnerId(v null.String) { + if gym.PartnerId != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("PartnerId:%s->%s", FormatNull(gym.PartnerId), FormatNull(v))) + } + gym.PartnerId = v + gym.dirty = true + } +} - saveGymRecord(ctx, db, gym) +func (gym *Gym) SetRaidPokemonCostume(v null.Int) { + if gym.RaidPokemonCostume != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonCostume:%s->%s", FormatNull(gym.RaidPokemonCostume), FormatNull(v))) + } + gym.RaidPokemonCostume = v + gym.dirty = true } +} - return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +func (gym *Gym) SetRaidPokemonEvolution(v null.Int) { + if gym.RaidPokemonEvolution != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("RaidPokemonEvolution:%s->%s", FormatNull(gym.RaidPokemonEvolution), FormatNull(v))) + } + gym.RaidPokemonEvolution = v + gym.dirty = true + } +} + +func (gym *Gym) SetArScanEligible(v null.Int) { + if gym.ArScanEligible != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("ArScanEligible:%s->%s", FormatNull(gym.ArScanEligible), FormatNull(v))) + } + gym.ArScanEligible = v + gym.dirty = true + } +} + +func (gym *Gym) SetPowerUpLevel(v null.Int) { + if gym.PowerUpLevel != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("PowerUpLevel:%s->%s", FormatNull(gym.PowerUpLevel), FormatNull(v))) + } + gym.PowerUpLevel = v + gym.dirty = true + } +} + +func (gym *Gym) SetPowerUpPoints(v null.Int) { + if gym.PowerUpPoints != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("PowerUpPoints:%s->%s", FormatNull(gym.PowerUpPoints), FormatNull(v))) + } + gym.PowerUpPoints = v + gym.dirty = true + } +} + +func (gym *Gym) SetPowerUpEndTimestamp(v null.Int) { + if gym.PowerUpEndTimestamp != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("PowerUpEndTimestamp:%s->%s", FormatNull(gym.PowerUpEndTimestamp), FormatNull(v))) + } + gym.PowerUpEndTimestamp = v + gym.dirty = true + } +} + +func (gym *Gym) SetDescription(v null.String) { + if gym.Description != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Description:%s->%s", FormatNull(gym.Description), FormatNull(v))) + } + gym.Description = v + gym.dirty = true + } +} + +func (gym *Gym) SetDefenders(v null.String) { + if gym.Defenders != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Defenders:%s->%s", FormatNull(gym.Defenders), FormatNull(v))) + } + gym.Defenders = v + //Do not set to dirty, as don't trigger an update + gym.internalDirty = true + } +} + +func (gym *Gym) SetRsvps(v null.String) { + if gym.Rsvps != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Rsvps:%s->%s", FormatNull(gym.Rsvps), FormatNull(v))) + } + gym.Rsvps = v + gym.dirty = true + } +} + +func (gym *Gym) SetUpdated(v int64) { + if gym.Updated != v { + if dbDebugEnabled { + gym.changedFields = append(gym.changedFields, fmt.Sprintf("Updated:%d->%d", gym.Updated, v)) + } + gym.Updated = v + gym.dirty = true + } } diff --git a/decoder/gym_decode.go b/decoder/gym_decode.go new file mode 100644 index 00000000..3bec72aa --- /dev/null +++ b/decoder/gym_decode.go @@ -0,0 +1,311 @@ +package decoder + +import ( + "cmp" + "encoding/json" + "slices" + "strings" + "time" + + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/pogo" + "golbat/util" +) + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + +func calculatePowerUpPoints(fortData *pogo.PokemonFortProto) (null.Int, null.Int) { + now := time.Now().Unix() + powerUpLevelExpirationMs := int64(fortData.PowerUpLevelExpirationMs) / 1000 + powerUpPoints := int64(fortData.PowerUpProgressPoints) + powerUpLevel := null.IntFrom(0) + powerUpEndTimestamp := null.NewInt(0, false) + if powerUpPoints < 50 { + powerUpLevel = null.IntFrom(0) + } else if powerUpPoints < 100 && powerUpLevelExpirationMs > now { + powerUpLevel = null.IntFrom(1) + powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) + } else if powerUpPoints < 150 && powerUpLevelExpirationMs > now { + powerUpLevel = null.IntFrom(2) + powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) + } else if powerUpLevelExpirationMs > now { + powerUpLevel = null.IntFrom(3) + powerUpEndTimestamp = null.IntFrom(powerUpLevelExpirationMs) + } else { + powerUpLevel = null.IntFrom(0) + } + + return powerUpLevel, powerUpEndTimestamp +} + +func (gym *Gym) updateGymFromFort(fortData *pogo.PokemonFortProto, cellId uint64) *Gym { + type pokemonDisplay struct { + Form int `json:"form,omitempty"` + Costume int `json:"costume,omitempty"` + Gender int `json:"gender"` + Shiny bool `json:"shiny,omitempty"` + TempEvolution int `json:"temp_evolution,omitempty"` + TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` + Alignment int `json:"alignment,omitempty"` + Badge int `json:"badge,omitempty"` + Background *int64 `json:"background,omitempty"` + } + gym.SetId(fortData.FortId) + gym.SetLat(fortData.Latitude) + gym.SetLon(fortData.Longitude) + gym.SetEnabled(null.IntFrom(util.BoolToInt[int64](fortData.Enabled))) + gym.SetGuardingPokemonId(null.IntFrom(int64(fortData.GuardPokemonId))) + if fortData.GuardPokemonDisplay == nil { + gym.SetGuardingPokemonDisplay(null.NewString("", false)) + } else { + display, _ := json.Marshal(pokemonDisplay{ + Form: int(fortData.GuardPokemonDisplay.Form), + Costume: int(fortData.GuardPokemonDisplay.Costume), + Gender: int(fortData.GuardPokemonDisplay.Gender), + Shiny: fortData.GuardPokemonDisplay.Shiny, + TempEvolution: int(fortData.GuardPokemonDisplay.CurrentTempEvolution), + TempEvolutionFinishMs: fortData.GuardPokemonDisplay.TemporaryEvolutionFinishMs, + Alignment: int(fortData.GuardPokemonDisplay.Alignment), + Badge: int(fortData.GuardPokemonDisplay.PokemonBadge), + Background: util.ExtractBackgroundFromDisplay(fortData.GuardPokemonDisplay), + }) + gym.SetGuardingPokemonDisplay(null.StringFrom(string(display))) + } + gym.SetTeamId(null.IntFrom(int64(fortData.Team))) + if fortData.GymDisplay != nil { + gym.SetAvailableSlots(null.IntFrom(int64(fortData.GymDisplay.SlotsAvailable))) + } else { + gym.SetAvailableSlots(null.IntFrom(6)) // this may be an incorrect assumption + } + gym.SetLastModifiedTimestamp(null.IntFrom(fortData.LastModifiedMs / 1000)) + gym.SetExRaidEligible(null.IntFrom(util.BoolToInt[int64](fortData.IsExRaidEligible))) + + if fortData.ImageUrl != "" { + gym.SetUrl(null.StringFrom(fortData.ImageUrl)) + } + gym.SetInBattle(null.IntFrom(util.BoolToInt[int64](fortData.IsInBattle))) + gym.SetArScanEligible(null.IntFrom(util.BoolToInt[int64](fortData.IsArScanEligible))) + gym.SetPowerUpPoints(null.IntFrom(int64(fortData.PowerUpProgressPoints))) + + powerUpLevel, powerUpEndTimestamp := calculatePowerUpPoints(fortData) + gym.SetPowerUpLevel(powerUpLevel) + gym.SetPowerUpEndTimestamp(powerUpEndTimestamp) + + if fortData.PartnerId == "" { + gym.SetPartnerId(null.NewString("", false)) + } else { + gym.SetPartnerId(null.StringFrom(fortData.PartnerId)) + } + + if fortData.ImageUrl != "" { + gym.SetUrl(null.StringFrom(fortData.ImageUrl)) + } + if fortData.Team == 0 { // check!! + gym.SetTotalCp(null.IntFrom(0)) + } else { + if fortData.GymDisplay != nil { + totalCp := int64(fortData.GymDisplay.TotalGymCp) + if gym.TotalCp.Int64-totalCp > 100 || totalCp-gym.TotalCp.Int64 > 100 { + gym.SetTotalCp(null.IntFrom(totalCp)) + } + } else { + gym.SetTotalCp(null.IntFrom(0)) + } + } + + if fortData.RaidInfo != nil { + gym.SetRaidEndTimestamp(null.IntFrom(int64(fortData.RaidInfo.RaidEndMs) / 1000)) + gym.SetRaidSpawnTimestamp(null.IntFrom(int64(fortData.RaidInfo.RaidSpawnMs) / 1000)) + raidBattleTimestamp := int64(fortData.RaidInfo.RaidBattleMs) / 1000 + + if gym.RaidBattleTimestamp.ValueOrZero() != raidBattleTimestamp { + // We are reporting a new raid, clear rsvp data + gym.SetRsvps(null.NewString("", false)) + } + gym.SetRaidBattleTimestamp(null.IntFrom(raidBattleTimestamp)) + + gym.SetRaidLevel(null.IntFrom(int64(fortData.RaidInfo.RaidLevel))) + if fortData.RaidInfo.RaidPokemon != nil { + gym.SetRaidPokemonId(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonId))) + gym.SetRaidPokemonMove1(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Move1))) + gym.SetRaidPokemonMove2(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Move2))) + gym.SetRaidPokemonForm(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Form))) + gym.SetRaidPokemonAlignment(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Alignment))) + gym.SetRaidPokemonCp(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.Cp))) + gym.SetRaidPokemonGender(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Gender))) + gym.SetRaidPokemonCostume(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.Costume))) + gym.SetRaidPokemonEvolution(null.IntFrom(int64(fortData.RaidInfo.RaidPokemon.PokemonDisplay.CurrentTempEvolution))) + } else { + gym.SetRaidPokemonId(null.IntFrom(0)) + gym.SetRaidPokemonMove1(null.IntFrom(0)) + gym.SetRaidPokemonMove2(null.IntFrom(0)) + gym.SetRaidPokemonForm(null.IntFrom(0)) + gym.SetRaidPokemonAlignment(null.IntFrom(0)) + gym.SetRaidPokemonCp(null.IntFrom(0)) + gym.SetRaidPokemonGender(null.IntFrom(0)) + gym.SetRaidPokemonCostume(null.IntFrom(0)) + gym.SetRaidPokemonEvolution(null.IntFrom(0)) + } + + gym.SetRaidIsExclusive(null.IntFrom(0)) //null.IntFrom(util.BoolToInt[int64](fortData.RaidInfo.IsExclusive)) + } + + gym.SetCellId(null.IntFrom(int64(cellId))) + + if gym.Deleted { + gym.SetDeleted(false) + log.Warnf("Cleared Gym with id '%s' is found again in GMO, therefore un-deleted", gym.Id) + // Restore in fort tracker if enabled + if fortTracker != nil { + fortTracker.RestoreFort(gym.Id, cellId, true, time.Now().Unix()) + } + } + + return gym +} + +func (gym *Gym) updateGymFromFortProto(fortData *pogo.FortDetailsOutProto) *Gym { + gym.SetId(fortData.Id) + gym.SetLat(fortData.Latitude) + gym.SetLon(fortData.Longitude) + if len(fortData.ImageUrl) > 0 { + gym.SetUrl(null.StringFrom(fortData.ImageUrl[0])) + } + gym.SetName(null.StringFrom(fortData.Name)) + + return gym +} + +func (gym *Gym) updateGymFromGymInfoOutProto(gymData *pogo.GymGetInfoOutProto) *Gym { + gym.SetId(gymData.GymStatusAndDefenders.PokemonFortProto.FortId) + gym.SetLat(gymData.GymStatusAndDefenders.PokemonFortProto.Latitude) + gym.SetLon(gymData.GymStatusAndDefenders.PokemonFortProto.Longitude) + + // This will have gym defenders in it... + if len(gymData.Url) > 0 { + gym.SetUrl(null.StringFrom(gymData.Url)) + } + gym.SetName(null.StringFrom(gymData.Name)) + + if gymData.Description == "" { + gym.SetDescription(null.NewString("", false)) + } else { + gym.SetDescription(null.StringFrom(gymData.Description)) + } + + type pokemonGymDefender struct { + PokemonId int `json:"pokemon_id,omitempty"` + Form int `json:"form,omitempty"` + Costume int `json:"costume,omitempty"` + Gender int `json:"gender"` + Shiny bool `json:"shiny,omitempty"` + TempEvolution int `json:"temp_evolution,omitempty"` + TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` + Alignment int `json:"alignment,omitempty"` + Badge int `json:"badge,omitempty"` + Background *int64 `json:"background,omitempty"` + DeployedMs int64 `json:"deployed_ms,omitempty"` + DeployedTime int64 `json:"deployed_time,omitempty"` + BattlesWon int32 `json:"battles_won"` + BattlesLost int32 `json:"battles_lost"` + TimesFed int32 `json:"times_fed"` + MotivationNow util.RoundedFloat4 `json:"motivation_now"` + CpNow int32 `json:"cp_now"` + CpWhenDeployed int32 `json:"cp_when_deployed"` + } + + var defenders []pokemonGymDefender + now := time.Now() + for _, protoDefender := range gymData.GymStatusAndDefenders.GymDefender { + motivatedPokemon := protoDefender.MotivatedPokemon + pokemonDisplay := motivatedPokemon.Pokemon.PokemonDisplay + deploymentTotals := protoDefender.DeploymentTotals + defender := pokemonGymDefender{ + DeployedMs: protoDefender.DeploymentTotals.DeploymentDurationMs, + DeployedTime: now. + Add(-1 * time.Millisecond * time.Duration(deploymentTotals.DeploymentDurationMs)). + Unix(), // This will only be approximately correct + BattlesLost: deploymentTotals.BattlesLost, + BattlesWon: deploymentTotals.BattlesWon, + TimesFed: deploymentTotals.TimesFed, + PokemonId: int(protoDefender.MotivatedPokemon.Pokemon.PokemonId), + Form: int(pokemonDisplay.Form), + Costume: int(pokemonDisplay.Costume), + Gender: int(pokemonDisplay.Gender), + TempEvolution: int(pokemonDisplay.CurrentTempEvolution), + TempEvolutionFinishMs: pokemonDisplay.TemporaryEvolutionFinishMs, + Alignment: int(pokemonDisplay.Alignment), + Badge: int(pokemonDisplay.PokemonBadge), + Background: util.ExtractBackgroundFromDisplay(pokemonDisplay), + Shiny: pokemonDisplay.Shiny, + MotivationNow: util.RoundedFloat4(motivatedPokemon.MotivationNow), + CpNow: motivatedPokemon.CpNow, + CpWhenDeployed: motivatedPokemon.CpWhenDeployed, + } + defenders = append(defenders, defender) + } + bDefenders, _ := json.Marshal(defenders) + gym.SetDefenders(null.StringFrom(string(bDefenders))) + // log.Debugf("Gym %s defenders %s ", gym.Id, string(bDefenders)) + + return gym +} + +func (gym *Gym) updateGymFromGetMapFortsOutProto(fortData *pogo.GetMapFortsOutProto_FortProto, skipName bool) *Gym { + gym.SetId(fortData.Id) + gym.SetLat(fortData.Latitude) + gym.SetLon(fortData.Longitude) + + if len(fortData.Image) > 0 { + gym.SetUrl(null.StringFrom(fortData.Image[0].Url)) + } + if !skipName { + gym.SetName(null.StringFrom(fortData.Name)) + } + + if gym.Deleted { + log.Debugf("Cleared Gym with id '%s' is found again in GMF, therefore kept deleted", gym.Id) + } + + return gym +} + +func (gym *Gym) updateGymFromRsvpProto(fortData *pogo.GetEventRsvpsOutProto) *Gym { + type rsvpTimeslot struct { + Timeslot int64 `json:"timeslot"` + GoingCount int32 `json:"going_count"` + MaybeCount int32 `json:"maybe_count"` + } + + timeslots := make([]rsvpTimeslot, 0) + + for _, timeslot := range fortData.RsvpTimeslots { + if timeslot.GoingCount > 0 || timeslot.MaybeCount > 0 { + timeslots = append(timeslots, rsvpTimeslot{ + Timeslot: timeslot.TimeSlot, + GoingCount: timeslot.GoingCount, + MaybeCount: timeslot.MaybeCount, + }) + } + } + + if len(timeslots) == 0 { + gym.SetRsvps(null.NewString("", false)) + } else { + slices.SortFunc(timeslots, func(a, b rsvpTimeslot) int { + return cmp.Compare(a.Timeslot, b.Timeslot) + }) + + bRsvps, _ := json.Marshal(timeslots) + gym.SetRsvps(null.StringFrom(string(bRsvps))) + } + + return gym +} diff --git a/decoder/gym_process.go b/decoder/gym_process.go new file mode 100644 index 00000000..1c34104c --- /dev/null +++ b/decoder/gym_process.go @@ -0,0 +1,97 @@ +package decoder + +import ( + "context" + "fmt" + + "github.com/guregu/null/v6" + + "golbat/db" + "golbat/pogo" +) + +func UpdateGymRecordWithFortDetailsOutProto(ctx context.Context, db db.DbDetails, fort *pogo.FortDetailsOutProto) string { + gym, unlock, err := getOrCreateGymRecord(ctx, db, fort.Id) + if err != nil { + return err.Error() + } + defer unlock() + + gym.updateGymFromFortProto(fort) + + updateGymGetMapFortCache(gym, true) + saveGymRecord(ctx, db, gym) + + return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +} + +func UpdateGymRecordWithGymInfoProto(ctx context.Context, db db.DbDetails, gymInfo *pogo.GymGetInfoOutProto) string { + gym, unlock, err := getOrCreateGymRecord(ctx, db, gymInfo.GymStatusAndDefenders.PokemonFortProto.FortId) + if err != nil { + return err.Error() + } + defer unlock() + + gym.updateGymFromGymInfoOutProto(gymInfo) + + updateGymGetMapFortCache(gym, true) + saveGymRecord(ctx, db, gym) + return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +} + +func UpdateGymRecordWithGetMapFortsOutProto(ctx context.Context, db db.DbDetails, mapFort *pogo.GetMapFortsOutProto_FortProto) (bool, string) { + gym, unlock, err := getGymRecordForUpdate(ctx, db, mapFort.Id) + if err != nil { + return false, err.Error() + } + + // we missed it in Pokestop & Gym. Lets save it to cache + if gym == nil { + return false, "" + } + defer unlock() + + gym.updateGymFromGetMapFortsOutProto(mapFort, false) + saveGymRecord(ctx, db, gym) + return true, fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +} + +func UpdateGymRecordWithRsvpProto(ctx context.Context, db db.DbDetails, req *pogo.RaidDetails, resp *pogo.GetEventRsvpsOutProto) string { + gym, unlock, err := getGymRecordForUpdate(ctx, db, req.FortId) + if err != nil { + return err.Error() + } + + if gym == nil { + // Do not add RSVP details to unknown gyms + return fmt.Sprintf("%s Gym not present", req.FortId) + } + defer unlock() + + gym.updateGymFromRsvpProto(resp) + + saveGymRecord(ctx, db, gym) + + return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +} + +func ClearGymRsvp(ctx context.Context, db db.DbDetails, fortId string) string { + gym, unlock, err := getGymRecordForUpdate(ctx, db, fortId) + if err != nil { + return err.Error() + } + + if gym == nil { + // Do not add RSVP details to unknown gyms + return fmt.Sprintf("%s Gym not present", fortId) + } + defer unlock() + + if gym.Rsvps.Valid { + gym.SetRsvps(null.NewString("", false)) + + saveGymRecord(ctx, db, gym) + } + + return fmt.Sprintf("%s %s", gym.Id, gym.Name.ValueOrZero()) +} diff --git a/decoder/gym_state.go b/decoder/gym_state.go new file mode 100644 index 00000000..e646ac1a --- /dev/null +++ b/decoder/gym_state.go @@ -0,0 +1,430 @@ +package decoder + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "golbat/config" + "golbat/db" + "golbat/geo" + "golbat/webhooks" + + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" +) + +func loadGymFromDatabase(ctx context.Context, db db.DbDetails, fortId string, gym *Gym) error { + err := db.GeneralDb.GetContext(ctx, gym, "SELECT id, lat, lon, name, url, last_modified_timestamp, raid_end_timestamp, raid_spawn_timestamp, raid_battle_timestamp, updated, raid_pokemon_id, guarding_pokemon_id, guarding_pokemon_display, available_slots, team_id, raid_level, enabled, ex_raid_eligible, in_battle, raid_pokemon_move_1, raid_pokemon_move_2, raid_pokemon_form, raid_pokemon_alignment, raid_pokemon_cp, raid_is_exclusive, cell_id, deleted, total_cp, first_seen_timestamp, raid_pokemon_gender, sponsor_id, partner_id, raid_pokemon_costume, raid_pokemon_evolution, ar_scan_eligible, power_up_level, power_up_points, power_up_end_timestamp, description, defenders, rsvps FROM gym WHERE id = ?", fortId) + statsCollector.IncDbQuery("select gym", err) + return err +} + +// PeekGymRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func PeekGymRecord(fortId string) (*Gym, func(), error) { + if item := gymCache.Get(fortId); item != nil { + gym := item.Value() + gym.Lock() + return gym, func() { gym.Unlock() }, nil + } + return nil, nil, nil +} + +// GetGymRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func GetGymRecordReadOnly(ctx context.Context, db db.DbDetails, fortId string) (*Gym, func(), error) { + // Check cache first + if item := gymCache.Get(fortId); item != nil { + gym := item.Value() + gym.Lock() + return gym, func() { gym.Unlock() }, nil + } + + dbGym := Gym{} + err := loadGymFromDatabase(ctx, db, fortId, &dbGym) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbGym.ClearDirty() + + // Atomically cache the loaded Gym - if another goroutine raced us, + // we'll get their Gym and use that instead (ensuring same mutex) + existingGym, _ := gymCache.GetOrSetFunc(fortId, func() *Gym { + // Only called if key doesn't exist - our Pokestop wins + if config.Config.TestFortInMemory { + fortRtreeUpdateGymOnGet(&dbGym) + } + return &dbGym + }) + + gym := existingGym.Value() + gym.Lock() + return gym, func() { gym.Unlock() }, nil +} + +// getGymRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Use when modifying the Gym. +// Caller MUST call returned unlock function if non-nil. +func getGymRecordForUpdate(ctx context.Context, db db.DbDetails, fortId string) (*Gym, func(), error) { + gym, unlock, err := GetGymRecordReadOnly(ctx, db, fortId) + if err != nil || gym == nil { + return nil, nil, err + } + gym.snapshotOldValues() + return gym, unlock, nil +} + +// getOrCreateGymRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreateGymRecord(ctx context.Context, db db.DbDetails, fortId string) (*Gym, func(), error) { + // Create new Gym atomically - function only called if key doesn't exist + gymItem, _ := gymCache.GetOrSetFunc(fortId, func() *Gym { + return &Gym{Id: fortId, newRecord: true} + }) + + gym := gymItem.Value() + gym.Lock() + + if gym.newRecord { + // We should attempt to load from database + err := loadGymFromDatabase(ctx, db, fortId, gym) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + gym.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + gym.newRecord = false + gym.ClearDirty() + if config.Config.TestFortInMemory { + fortRtreeUpdateGymOnGet(gym) + } + } + } + + gym.snapshotOldValues() + return gym, func() { gym.Unlock() }, nil +} + +// hasChangesGym compares two Gym structs +// Float tolerance: Lat, Lon +func hasChangesGym(old *Gym, new *Gym) bool { + return old.Id != new.Id || + old.Name != new.Name || + old.Url != new.Url || + old.LastModifiedTimestamp != new.LastModifiedTimestamp || + old.RaidEndTimestamp != new.RaidEndTimestamp || + old.RaidSpawnTimestamp != new.RaidSpawnTimestamp || + old.RaidBattleTimestamp != new.RaidBattleTimestamp || + old.Updated != new.Updated || + old.RaidPokemonId != new.RaidPokemonId || + old.GuardingPokemonId != new.GuardingPokemonId || + old.AvailableSlots != new.AvailableSlots || + old.TeamId != new.TeamId || + old.RaidLevel != new.RaidLevel || + old.Enabled != new.Enabled || + old.ExRaidEligible != new.ExRaidEligible || + // old.InBattle != new.InBattle || + old.RaidPokemonMove1 != new.RaidPokemonMove1 || + old.RaidPokemonMove2 != new.RaidPokemonMove2 || + old.RaidPokemonForm != new.RaidPokemonForm || + old.RaidPokemonAlignment != new.RaidPokemonAlignment || + old.RaidPokemonCp != new.RaidPokemonCp || + old.RaidIsExclusive != new.RaidIsExclusive || + old.CellId != new.CellId || + old.Deleted != new.Deleted || + old.TotalCp != new.TotalCp || + old.FirstSeenTimestamp != new.FirstSeenTimestamp || + old.RaidPokemonGender != new.RaidPokemonGender || + old.SponsorId != new.SponsorId || + old.PartnerId != new.PartnerId || + old.RaidPokemonCostume != new.RaidPokemonCostume || + old.RaidPokemonEvolution != new.RaidPokemonEvolution || + old.ArScanEligible != new.ArScanEligible || + old.PowerUpLevel != new.PowerUpLevel || + old.PowerUpPoints != new.PowerUpPoints || + old.PowerUpEndTimestamp != new.PowerUpEndTimestamp || + old.Description != new.Description || + old.Rsvps != new.Rsvps || + !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || + !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) + +} + +// hasChangesInternalGym compares two Gym structs for changes that will be stored in memory +// Float tolerance: Lat, Lon +func hasInternalChangesGym(old *Gym, new *Gym) bool { + return old.InBattle != new.InBattle || + old.Defenders != new.Defenders +} + +type GymDetailsWebhook struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Team int64 `json:"team"` + GuardPokemonId int64 `json:"guard_pokemon_id"` + SlotsAvailable int64 `json:"slots_available"` + ExRaidEligible int64 `json:"ex_raid_eligible"` + InBattle bool `json:"in_battle"` + SponsorId int64 `json:"sponsor_id"` + PartnerId int64 `json:"partner_id"` + PowerUpPoints int64 `json:"power_up_points"` + PowerUpLevel int64 `json:"power_up_level"` + PowerUpEndTimestamp int64 `json:"power_up_end_timestamp"` + ArScanEligible int64 `json:"ar_scan_eligible"` + Defenders any `json:"defenders"` +} + +type RaidWebhook struct { + GymId string `json:"gym_id"` + GymName string `json:"gym_name"` + GymUrl string `json:"gym_url"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + TeamId int64 `json:"team_id"` + Spawn int64 `json:"spawn"` + Start int64 `json:"start"` + End int64 `json:"end"` + Level int64 `json:"level"` + PokemonId int64 `json:"pokemon_id"` + Cp int64 `json:"cp"` + Gender int64 `json:"gender"` + Form int64 `json:"form"` + Alignment int64 `json:"alignment"` + Costume int64 `json:"costume"` + Evolution int64 `json:"evolution"` + Move1 int64 `json:"move_1"` + Move2 int64 `json:"move_2"` + ExRaidEligible int64 `json:"ex_raid_eligible"` + IsExclusive int64 `json:"is_exclusive"` + SponsorId int64 `json:"sponsor_id"` + PartnerId string `json:"partner_id"` + PowerUpPoints int64 `json:"power_up_points"` + PowerUpLevel int64 `json:"power_up_level"` + PowerUpEndTimestamp int64 `json:"power_up_end_timestamp"` + ArScanEligible int64 `json:"ar_scan_eligible"` + Rsvps json.RawMessage `json:"rsvps"` +} + +func createGymFortWebhooks(gym *Gym) { + fort := InitWebHookFortFromGym(gym) + if gym.newRecord { + CreateFortWebHooks(nil, fort, NEW) + } else { + // Build old fort from saved old values + oldFort := &FortWebhook{ + Type: GYM.String(), + Id: gym.Id, + Name: gym.oldValues.Name.Ptr(), + ImageUrl: gym.oldValues.Url.Ptr(), + Description: gym.oldValues.Description.Ptr(), + Location: Location{Latitude: gym.oldValues.Lat, Longitude: gym.oldValues.Lon}, + } + CreateFortWebHooks(oldFort, fort, EDIT) + } +} + +func createGymWebhooks(gym *Gym, areas []geo.AreaName) { + if gym.newRecord || + (gym.oldValues.AvailableSlots != gym.AvailableSlots || gym.oldValues.TeamId != gym.TeamId || gym.oldValues.InBattle != gym.InBattle) { + gymDetails := GymDetailsWebhook{ + Id: gym.Id, + Name: gym.Name.ValueOrZero(), + Url: gym.Url.ValueOrZero(), + Latitude: gym.Lat, + Longitude: gym.Lon, + Team: gym.TeamId.ValueOrZero(), + GuardPokemonId: gym.GuardingPokemonId.ValueOrZero(), + SlotsAvailable: func() int64 { + if gym.AvailableSlots.Valid { + return gym.AvailableSlots.Int64 + } else { + return 6 + } + }(), + ExRaidEligible: gym.ExRaidEligible.ValueOrZero(), + InBattle: func() bool { return gym.InBattle.ValueOrZero() != 0 }(), + Defenders: func() any { + if gym.Defenders.Valid { + return json.RawMessage(gym.Defenders.ValueOrZero()) + } else { + return nil + } + }(), + } + + webhooksSender.AddMessage(webhooks.GymDetails, gymDetails, areas) + } + + if gym.RaidSpawnTimestamp.ValueOrZero() > 0 && + (gym.newRecord || gym.oldValues.RaidLevel != gym.RaidLevel || + gym.oldValues.RaidPokemonId != gym.RaidPokemonId || + gym.oldValues.RaidSpawnTimestamp != gym.RaidSpawnTimestamp || gym.oldValues.Rsvps != gym.Rsvps) { + raidBattleTime := gym.RaidBattleTimestamp.ValueOrZero() + raidEndTime := gym.RaidEndTimestamp.ValueOrZero() + now := time.Now().Unix() + + if (raidBattleTime > now && gym.RaidLevel.ValueOrZero() > 0) || + (raidEndTime > now && gym.RaidPokemonId.ValueOrZero() > 0) { + gymName := "Unknown" + if gym.Name.Valid { + gymName = gym.Name.String + } + + var rsvps json.RawMessage + if gym.Rsvps.Valid { + rsvps = json.RawMessage(gym.Rsvps.ValueOrZero()) + } + + raidHook := RaidWebhook{ + GymId: gym.Id, + GymName: gymName, + GymUrl: gym.Url.ValueOrZero(), + Latitude: gym.Lat, + Longitude: gym.Lon, + TeamId: gym.TeamId.ValueOrZero(), + Spawn: gym.RaidSpawnTimestamp.ValueOrZero(), + Start: gym.RaidBattleTimestamp.ValueOrZero(), + End: gym.RaidEndTimestamp.ValueOrZero(), + Level: gym.RaidLevel.ValueOrZero(), + PokemonId: gym.RaidPokemonId.ValueOrZero(), + Cp: gym.RaidPokemonCp.ValueOrZero(), + Gender: gym.RaidPokemonGender.ValueOrZero(), + Form: gym.RaidPokemonForm.ValueOrZero(), + Alignment: gym.RaidPokemonAlignment.ValueOrZero(), + Costume: gym.RaidPokemonCostume.ValueOrZero(), + Evolution: gym.RaidPokemonEvolution.ValueOrZero(), + Move1: gym.RaidPokemonMove1.ValueOrZero(), + Move2: gym.RaidPokemonMove2.ValueOrZero(), + ExRaidEligible: gym.ExRaidEligible.ValueOrZero(), + IsExclusive: gym.RaidIsExclusive.ValueOrZero(), + SponsorId: gym.SponsorId.ValueOrZero(), + PartnerId: gym.PartnerId.ValueOrZero(), + PowerUpPoints: gym.PowerUpPoints.ValueOrZero(), + PowerUpLevel: gym.PowerUpLevel.ValueOrZero(), + PowerUpEndTimestamp: gym.PowerUpEndTimestamp.ValueOrZero(), + ArScanEligible: gym.ArScanEligible.ValueOrZero(), + Rsvps: rsvps, + } + + webhooksSender.AddMessage(webhooks.Raid, raidHook, areas) + statsCollector.UpdateRaidCount(areas, gym.RaidLevel.ValueOrZero()) + } + } +} + +func saveGymRecord(ctx context.Context, db db.DbDetails, gym *Gym) { + now := time.Now().Unix() + if !gym.IsNewRecord() && !gym.IsDirty() && !gym.IsInternalDirty() { + // default debounce is 15 minutes (900s). If reduce_updates is enabled, use 12 hours. + if gym.Updated > now-GetUpdateThreshold(900) { + // if a gym is unchanged and was seen recently, skip saving + return + } + } + gym.SetUpdated(now) + + if gym.IsDirty() { + if gym.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Gym", gym.Id, gym.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, "INSERT INTO gym (id,lat,lon,name,url,last_modified_timestamp,raid_end_timestamp,raid_spawn_timestamp,raid_battle_timestamp,updated,raid_pokemon_id,guarding_pokemon_id,guarding_pokemon_display,available_slots,team_id,raid_level,enabled,ex_raid_eligible,in_battle,raid_pokemon_move_1,raid_pokemon_move_2,raid_pokemon_form,raid_pokemon_alignment,raid_pokemon_cp,raid_is_exclusive,cell_id,deleted,total_cp,first_seen_timestamp,raid_pokemon_gender,sponsor_id,partner_id,raid_pokemon_costume,raid_pokemon_evolution,ar_scan_eligible,power_up_level,power_up_points,power_up_end_timestamp,description, defenders, rsvps) "+ + "VALUES (:id,:lat,:lon,:name,:url,UNIX_TIMESTAMP(),:raid_end_timestamp,:raid_spawn_timestamp,:raid_battle_timestamp,:updated,:raid_pokemon_id,:guarding_pokemon_id,:guarding_pokemon_display,:available_slots,:team_id,:raid_level,:enabled,:ex_raid_eligible,:in_battle,:raid_pokemon_move_1,:raid_pokemon_move_2,:raid_pokemon_form,:raid_pokemon_alignment,:raid_pokemon_cp,:raid_is_exclusive,:cell_id,0,:total_cp,UNIX_TIMESTAMP(),:raid_pokemon_gender,:sponsor_id,:partner_id,:raid_pokemon_costume,:raid_pokemon_evolution,:ar_scan_eligible,:power_up_level,:power_up_points,:power_up_end_timestamp,:description, :defenders, :rsvps)", gym) + + statsCollector.IncDbQuery("insert gym", err) + if err != nil { + log.Errorf("insert gym: %s", err) + return + } + + _, _ = res, err + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Gym", gym.Id, gym.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, "UPDATE gym SET "+ + "lat = :lat, "+ + "lon = :lon, "+ + "name = :name, "+ + "url = :url, "+ + "last_modified_timestamp = :last_modified_timestamp, "+ + "raid_end_timestamp = :raid_end_timestamp, "+ + "raid_spawn_timestamp = :raid_spawn_timestamp, "+ + "raid_battle_timestamp = :raid_battle_timestamp, "+ + "updated = :updated, "+ + "raid_pokemon_id = :raid_pokemon_id, "+ + "guarding_pokemon_id = :guarding_pokemon_id, "+ + "guarding_pokemon_display = :guarding_pokemon_display, "+ + "available_slots = :available_slots, "+ + "team_id = :team_id, "+ + "raid_level = :raid_level, "+ + "enabled = :enabled, "+ + "ex_raid_eligible = :ex_raid_eligible, "+ + "in_battle = :in_battle, "+ + "raid_pokemon_move_1 = :raid_pokemon_move_1, "+ + "raid_pokemon_move_2 = :raid_pokemon_move_2, "+ + "raid_pokemon_form = :raid_pokemon_form, "+ + "raid_pokemon_alignment = :raid_pokemon_alignment, "+ + "raid_pokemon_cp = :raid_pokemon_cp, "+ + "raid_is_exclusive = :raid_is_exclusive, "+ + "cell_id = :cell_id, "+ + "deleted = :deleted, "+ + "total_cp = :total_cp, "+ + "raid_pokemon_gender = :raid_pokemon_gender, "+ + "sponsor_id = :sponsor_id, "+ + "partner_id = :partner_id, "+ + "raid_pokemon_costume = :raid_pokemon_costume, "+ + "raid_pokemon_evolution = :raid_pokemon_evolution, "+ + "ar_scan_eligible = :ar_scan_eligible, "+ + "power_up_level = :power_up_level, "+ + "power_up_points = :power_up_points, "+ + "power_up_end_timestamp = :power_up_end_timestamp,"+ + "description = :description,"+ + "defenders = :defenders,"+ + "rsvps = :rsvps "+ + "WHERE id = :id", gym, + ) + statsCollector.IncDbQuery("update gym", err) + if err != nil { + log.Errorf("Update gym %s", err) + } + _, _ = res, err + } + } + + //gymCache.Set(gym.Id, gym, ttlcache.DefaultTTL) + areas := MatchStatsGeofence(gym.Lat, gym.Lon) + createGymWebhooks(gym, areas) + createGymFortWebhooks(gym) + updateRaidStats(gym, areas) + if dbDebugEnabled { + gym.changedFields = gym.changedFields[:0] + } + if gym.IsNewRecord() { + gymCache.Set(gym.Id, gym, ttlcache.DefaultTTL) + gym.newRecord = false + } + gym.ClearDirty() +} + +func updateGymGetMapFortCache(gym *Gym, skipName bool) { + storedGetMapFort := getMapFortsCache.Get(gym.Id) + if storedGetMapFort != nil { + getMapFort := storedGetMapFort.Value() + getMapFortsCache.Delete(gym.Id) + gym.updateGymFromGetMapFortsOutProto(getMapFort, skipName) + log.Debugf("Updated Gym using stored getMapFort: %s", gym.Id) + } +} diff --git a/decoder/incident.go b/decoder/incident.go index 6d2a7dce..c30e135d 100644 --- a/decoder/incident.go +++ b/decoder/incident.go @@ -1,22 +1,17 @@ package decoder import ( - "context" - "database/sql" - "time" + "fmt" + "sync" - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - null "gopkg.in/guregu/null.v4" - - "golbat/db" - "golbat/pogo" - "golbat/webhooks" + "github.com/guregu/null/v6" ) // Incident struct. -// REMINDER! Keep hasChangesIncident updated after making changes +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Incident struct { + mu sync.Mutex `db:"-"` // Object-level mutex + Id string `db:"id"` PokestopId string `db:"pokestop_id"` StartTime int64 `db:"start"` @@ -32,6 +27,21 @@ type Incident struct { Slot2Form null.Int `db:"slot_2_form"` Slot3PokemonId null.Int `db:"slot_3_pokemon_id"` Slot3Form null.Int `db:"slot_3_form"` + + dirty bool `db:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues IncidentOldValues `db:"-"` // Old values for webhook comparison +} + +// IncidentOldValues holds old field values for webhook comparison and stats +type IncidentOldValues struct { + StartTime int64 + ExpirationTime int64 + Character int16 + Confirmed bool + Slot1PokemonId null.Int } type webhookLineup struct { @@ -40,6 +50,26 @@ type webhookLineup struct { Form null.Int `json:"form"` } +type IncidentWebhook struct { + Id string `json:"id"` + PokestopId string `json:"pokestop_id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + PokestopName string `json:"pokestop_name"` + Url string `json:"url"` + Enabled bool `json:"enabled"` + Start int64 `json:"start"` + IncidentExpireTimestamp int64 `json:"incident_expire_timestamp"` + Expiration int64 `json:"expiration"` + DisplayType int16 `json:"display_type"` + Style int16 `json:"style"` + GruntType int16 `json:"grunt_type"` + Character int16 `json:"character"` + Updated int64 `json:"updated"` + Confirmed bool `json:"confirmed"` + Lineup []webhookLineup `json:"lineup"` +} + //-> `id` varchar(35) NOT NULL, //-> `pokestop_id` varchar(35) NOT NULL, //-> `start` int unsigned NOT NULL, @@ -49,209 +79,191 @@ type webhookLineup struct { //-> `character` smallint unsigned NOT NULL, //-> `updated` int unsigned NOT NULL, -func getIncidentRecord(ctx context.Context, db db.DbDetails, incidentId string) (*Incident, error) { - inMemoryIncident := incidentCache.Get(incidentId) - if inMemoryIncident != nil { - incident := inMemoryIncident.Value() - return &incident, nil - } - - incident := Incident{} - err := db.GeneralDb.GetContext(ctx, &incident, - "SELECT id, pokestop_id, start, expiration, display_type, style, `character`, updated, confirmed, slot_1_pokemon_id, slot_1_form, slot_2_pokemon_id, slot_2_form, slot_3_pokemon_id, slot_3_form "+ - "FROM incident "+ - "WHERE incident.id = ? ", incidentId) - statsCollector.IncDbQuery("select incident", err) - if err == sql.ErrNoRows { - return nil, nil - } - - if err != nil { - return nil, err - } +// IsDirty returns true if any field has been modified +func (incident *Incident) IsDirty() bool { + return incident.dirty +} - incidentCache.Set(incidentId, incident, ttlcache.DefaultTTL) - return &incident, nil +// ClearDirty resets the dirty flag (call after saving to DB) +func (incident *Incident) ClearDirty() { + incident.dirty = false } -// hasChangesIncident compares two Incident structs -func hasChangesIncident(old *Incident, new *Incident) bool { - return old.Id != new.Id || - old.PokestopId != new.PokestopId || - old.StartTime != new.StartTime || - old.ExpirationTime != new.ExpirationTime || - old.DisplayType != new.DisplayType || - old.Style != new.Style || - old.Character != new.Character || - old.Confirmed != new.Confirmed || - old.Updated != new.Updated || - old.Slot1PokemonId != new.Slot1PokemonId || - old.Slot1Form != new.Slot1Form || - old.Slot2PokemonId != new.Slot2PokemonId || - old.Slot2Form != new.Slot2Form || - old.Slot3PokemonId != new.Slot3PokemonId || - old.Slot3Form != new.Slot3Form +// IsNewRecord returns true if this is a new record (not yet in DB) +func (incident *Incident) IsNewRecord() bool { + return incident.newRecord +} +// Lock acquires the Incident's mutex +func (incident *Incident) Lock() { + incident.mu.Lock() } -func saveIncidentRecord(ctx context.Context, db db.DbDetails, incident *Incident) { - oldIncident, _ := getIncidentRecord(ctx, db, incident.Id) +// Unlock releases the Incident's mutex +func (incident *Incident) Unlock() { + incident.mu.Unlock() +} - if oldIncident != nil && !hasChangesIncident(oldIncident, incident) { - return +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (incident *Incident) snapshotOldValues() { + incident.oldValues = IncidentOldValues{ + StartTime: incident.StartTime, + ExpirationTime: incident.ExpirationTime, + Character: incident.Character, + Confirmed: incident.Confirmed, + Slot1PokemonId: incident.Slot1PokemonId, } +} - //log.Traceln(cmp.Diff(oldIncident, incident)) +// --- Set methods with dirty tracking --- - incident.Updated = time.Now().Unix() +func (incident *Incident) SetId(v string) { + if incident.Id != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Id:%s->%s", incident.Id, v)) + } + incident.Id = v + incident.dirty = true + } +} - //log.Println(cmp.Diff(oldIncident, incident)) +func (incident *Incident) SetPokestopId(v string) { + if incident.PokestopId != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("PokestopId:%s->%s", incident.PokestopId, v)) + } + incident.PokestopId = v + incident.dirty = true + } +} - if oldIncident == nil { - res, err := db.GeneralDb.NamedExec("INSERT INTO incident (id, pokestop_id, start, expiration, display_type, style, `character`, updated, confirmed, slot_1_pokemon_id, slot_1_form, slot_2_pokemon_id, slot_2_form, slot_3_pokemon_id, slot_3_form) "+ - "VALUES (:id, :pokestop_id, :start, :expiration, :display_type, :style, :character, :updated, :confirmed, :slot_1_pokemon_id, :slot_1_form, :slot_2_pokemon_id, :slot_2_form, :slot_3_pokemon_id, :slot_3_form)", incident) +func (incident *Incident) SetStartTime(v int64) { + if incident.StartTime != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("StartTime:%d->%d", incident.StartTime, v)) + } + incident.StartTime = v + incident.dirty = true + } +} - if err != nil { - log.Errorf("insert incident: %s", err) - return +func (incident *Incident) SetExpirationTime(v int64) { + if incident.ExpirationTime != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("ExpirationTime:%d->%d", incident.ExpirationTime, v)) } - statsCollector.IncDbQuery("insert incident", err) - _, _ = res, err - } else { - res, err := db.GeneralDb.NamedExec("UPDATE incident SET "+ - "start = :start, "+ - "expiration = :expiration, "+ - "display_type = :display_type, "+ - "style = :style, "+ - "`character` = :character, "+ - "updated = :updated, "+ - "confirmed = :confirmed, "+ - "slot_1_pokemon_id = :slot_1_pokemon_id, "+ - "slot_1_form = :slot_1_form, "+ - "slot_2_pokemon_id = :slot_2_pokemon_id, "+ - "slot_2_form = :slot_2_form, "+ - "slot_3_pokemon_id = :slot_3_pokemon_id, "+ - "slot_3_form = :slot_3_form "+ - "WHERE id = :id", incident, - ) - statsCollector.IncDbQuery("update incident", err) - if err != nil { - log.Errorf("Update incident %s", err) + incident.ExpirationTime = v + incident.dirty = true + } +} + +func (incident *Incident) SetDisplayType(v int16) { + if incident.DisplayType != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("DisplayType:%d->%d", incident.DisplayType, v)) } - _, _ = res, err + incident.DisplayType = v + incident.dirty = true } +} - incidentCache.Set(incident.Id, *incident, ttlcache.DefaultTTL) - createIncidentWebhooks(ctx, db, oldIncident, incident) +func (incident *Incident) SetStyle(v int16) { + if incident.Style != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Style:%d->%d", incident.Style, v)) + } + incident.Style = v + incident.dirty = true + } +} - stop, _ := GetPokestopRecord(ctx, db, incident.PokestopId) - if stop == nil { - stop = &Pokestop{} +func (incident *Incident) SetCharacter(v int16) { + if incident.Character != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Character:%d->%d", incident.Character, v)) + } + incident.Character = v + incident.dirty = true } +} - areas := MatchStatsGeofence(stop.Lat, stop.Lon) - updateIncidentStats(oldIncident, incident, areas) +func (incident *Incident) SetConfirmed(v bool) { + if incident.Confirmed != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Confirmed:%t->%t", incident.Confirmed, v)) + } + incident.Confirmed = v + incident.dirty = true + } } -func createIncidentWebhooks(ctx context.Context, db db.DbDetails, oldIncident *Incident, incident *Incident) { - if oldIncident == nil || (oldIncident.ExpirationTime != incident.ExpirationTime || oldIncident.Character != incident.Character || oldIncident.Confirmed != incident.Confirmed || oldIncident.Slot1PokemonId != incident.Slot1PokemonId) { - stop, _ := GetPokestopRecord(ctx, db, incident.PokestopId) - if stop == nil { - stop = &Pokestop{} +func (incident *Incident) SetSlot1PokemonId(v null.Int) { + if incident.Slot1PokemonId != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot1PokemonId:%s->%s", FormatNull(incident.Slot1PokemonId), FormatNull(v))) } + incident.Slot1PokemonId = v + incident.dirty = true + } +} - incidentHook := map[string]interface{}{ - "id": incident.Id, - "pokestop_id": incident.PokestopId, - "latitude": stop.Lat, - "longitude": stop.Lon, - "pokestop_name": func() string { - if stop.Name.Valid { - return stop.Name.String - } else { - return "Unknown" - } - }(), - "url": stop.Url.ValueOrZero(), - "enabled": stop.Enabled.ValueOrZero(), - "start": incident.StartTime, - "incident_expire_timestamp": incident.ExpirationTime, // deprecated, remove old key in the future - "expiration": incident.ExpirationTime, - "display_type": incident.DisplayType, - "style": incident.Style, - "grunt_type": incident.Character, // deprecated, remove old key in the future - "character": incident.Character, - "updated": incident.Updated, - "confirmed": incident.Confirmed, - "lineup": nil, +func (incident *Incident) SetSlot1Form(v null.Int) { + if incident.Slot1Form != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot1Form:%s->%s", FormatNull(incident.Slot1Form), FormatNull(v))) } + incident.Slot1Form = v + incident.dirty = true + } +} - if incident.Slot1PokemonId.Valid { - incidentHook["lineup"] = []webhookLineup{ - { - Slot: 1, - PokemonId: incident.Slot1PokemonId, - Form: incident.Slot1Form, - }, - { - Slot: 2, - PokemonId: incident.Slot2PokemonId, - Form: incident.Slot2Form, - }, - { - Slot: 3, - PokemonId: incident.Slot3PokemonId, - Form: incident.Slot3Form, - }, - } +func (incident *Incident) SetSlot2PokemonId(v null.Int) { + if incident.Slot2PokemonId != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot2PokemonId:%s->%s", FormatNull(incident.Slot2PokemonId), FormatNull(v))) } - areas := MatchStatsGeofence(stop.Lat, stop.Lon) - webhooksSender.AddMessage(webhooks.Invasion, incidentHook, areas) - statsCollector.UpdateIncidentCount(areas) + incident.Slot2PokemonId = v + incident.dirty = true } } -func (incident *Incident) updateFromPokestopIncidentDisplay(pokestopDisplay *pogo.PokestopIncidentDisplayProto) { - incident.Id = pokestopDisplay.IncidentId - incident.StartTime = int64(pokestopDisplay.IncidentStartMs / 1000) - incident.ExpirationTime = int64(pokestopDisplay.IncidentExpirationMs / 1000) - incident.DisplayType = int16(pokestopDisplay.IncidentDisplayType) - if (incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_MALE) || incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_FEMALE)) && incident.Confirmed { - log.Debugf("Incident has already been confirmed as a decoy: %s", incident.Id) - return +func (incident *Incident) SetSlot2Form(v null.Int) { + if incident.Slot2Form != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot2Form:%s->%s", FormatNull(incident.Slot2Form), FormatNull(v))) + } + incident.Slot2Form = v + incident.dirty = true } - characterDisplay := pokestopDisplay.GetCharacterDisplay() - if characterDisplay != nil { - // team := pokestopDisplay.Open - incident.Style = int16(characterDisplay.Style) - incident.Character = int16(characterDisplay.Character) - } else { - incident.Style, incident.Character = 0, 0 +} + +func (incident *Incident) SetSlot3PokemonId(v null.Int) { + if incident.Slot3PokemonId != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot3PokemonId:%s->%s", FormatNull(incident.Slot3PokemonId), FormatNull(v))) + } + incident.Slot3PokemonId = v + incident.dirty = true } } -func (incident *Incident) updateFromOpenInvasionCombatSessionOut(protoRes *pogo.OpenInvasionCombatSessionOutProto) { - incident.Slot1PokemonId = null.NewInt(int64(protoRes.Combat.Opponent.ActivePokemon.PokedexId.Number()), true) - incident.Slot1Form = null.NewInt(int64(protoRes.Combat.Opponent.ActivePokemon.PokemonDisplay.Form.Number()), true) - for i, pokemon := range protoRes.Combat.Opponent.ReservePokemon { - if i == 0 { - incident.Slot2PokemonId = null.NewInt(int64(pokemon.PokedexId.Number()), true) - incident.Slot2Form = null.NewInt(int64(pokemon.PokemonDisplay.Form.Number()), true) - } else if i == 1 { - incident.Slot3PokemonId = null.NewInt(int64(pokemon.PokedexId.Number()), true) - incident.Slot3Form = null.NewInt(int64(pokemon.PokemonDisplay.Form.Number()), true) +func (incident *Incident) SetSlot3Form(v null.Int) { + if incident.Slot3Form != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Slot3Form:%s->%s", FormatNull(incident.Slot3Form), FormatNull(v))) } + incident.Slot3Form = v + incident.dirty = true } - incident.Confirmed = true } -func (incident *Incident) updateFromStartIncidentOut(proto *pogo.StartIncidentOutProto) { - incident.Character = int16(proto.GetIncident().GetStep()[0].GetPokestopDialogue().GetDialogueLine()[0].GetCharacter()) - if incident.Character == int16(pogo.EnumWrapper_CHARACTER_GIOVANNI) || - incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_MALE) || - incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_FEMALE) { - incident.Confirmed = true +func (incident *Incident) SetUpdated(v int64) { + if incident.Updated != v { + if dbDebugEnabled { + incident.changedFields = append(incident.changedFields, fmt.Sprintf("Updated:%d->%d", incident.Updated, v)) + } + incident.Updated = v + incident.dirty = true } - incident.StartTime = int64(proto.Incident.GetCompletionDisplay().GetIncidentStartMs() / 1000) - incident.ExpirationTime = int64(proto.Incident.GetCompletionDisplay().GetIncidentExpirationMs() / 1000) } diff --git a/decoder/incident_decode.go b/decoder/incident_decode.go new file mode 100644 index 00000000..046105fc --- /dev/null +++ b/decoder/incident_decode.go @@ -0,0 +1,54 @@ +package decoder + +import ( + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/pogo" +) + +func (incident *Incident) updateFromPokestopIncidentDisplay(pokestopDisplay *pogo.PokestopIncidentDisplayProto) { + incident.SetId(pokestopDisplay.IncidentId) + incident.SetStartTime(int64(pokestopDisplay.IncidentStartMs / 1000)) + incident.SetExpirationTime(int64(pokestopDisplay.IncidentExpirationMs / 1000)) + incident.SetDisplayType(int16(pokestopDisplay.IncidentDisplayType)) + if (incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_MALE) || incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_FEMALE)) && incident.Confirmed { + log.Debugf("Incident has already been confirmed as a decoy: %s", incident.Id) + return + } + characterDisplay := pokestopDisplay.GetCharacterDisplay() + if characterDisplay != nil { + // team := pokestopDisplay.Open + incident.SetStyle(int16(characterDisplay.Style)) + incident.SetCharacter(int16(characterDisplay.Character)) + } else { + incident.SetStyle(0) + incident.SetCharacter(0) + } +} + +func (incident *Incident) updateFromOpenInvasionCombatSessionOut(protoRes *pogo.OpenInvasionCombatSessionOutProto) { + incident.SetSlot1PokemonId(null.NewInt(int64(protoRes.Combat.Opponent.ActivePokemon.PokedexId.Number()), true)) + incident.SetSlot1Form(null.NewInt(int64(protoRes.Combat.Opponent.ActivePokemon.PokemonDisplay.Form.Number()), true)) + for i, pokemon := range protoRes.Combat.Opponent.ReservePokemon { + if i == 0 { + incident.SetSlot2PokemonId(null.NewInt(int64(pokemon.PokedexId.Number()), true)) + incident.SetSlot2Form(null.NewInt(int64(pokemon.PokemonDisplay.Form.Number()), true)) + } else if i == 1 { + incident.SetSlot3PokemonId(null.NewInt(int64(pokemon.PokedexId.Number()), true)) + incident.SetSlot3Form(null.NewInt(int64(pokemon.PokemonDisplay.Form.Number()), true)) + } + } + incident.SetConfirmed(true) +} + +func (incident *Incident) updateFromStartIncidentOut(proto *pogo.StartIncidentOutProto) { + incident.SetCharacter(int16(proto.GetIncident().GetStep()[0].GetPokestopDialogue().GetDialogueLine()[0].GetCharacter())) + if incident.Character == int16(pogo.EnumWrapper_CHARACTER_GIOVANNI) || + incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_MALE) || + incident.Character == int16(pogo.EnumWrapper_CHARACTER_DECOY_GRUNT_FEMALE) { + incident.SetConfirmed(true) + } + incident.SetStartTime(int64(proto.Incident.GetCompletionDisplay().GetIncidentStartMs() / 1000)) + incident.SetExpirationTime(int64(proto.Incident.GetCompletionDisplay().GetIncidentExpirationMs() / 1000)) +} diff --git a/decoder/incident_process.go b/decoder/incident_process.go new file mode 100644 index 00000000..4550c7c9 --- /dev/null +++ b/decoder/incident_process.go @@ -0,0 +1,43 @@ +package decoder + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func UpdateIncidentLineup(ctx context.Context, db db.DbDetails, protoReq *pogo.OpenInvasionCombatSessionProto, protoRes *pogo.OpenInvasionCombatSessionOutProto) string { + incident, unlock, err := getOrCreateIncidentRecord(ctx, db, protoReq.IncidentLookup.IncidentId, protoReq.IncidentLookup.FortId) + if err != nil { + return fmt.Sprintf("getOrCreateIncidentRecord: %s", err) + } + defer unlock() + + if incident.newRecord { + log.Debugf("Updating lineup before it was saved: %s", protoReq.IncidentLookup.IncidentId) + } + incident.updateFromOpenInvasionCombatSessionOut(protoRes) + + saveIncidentRecord(ctx, db, incident) + return "" +} + +func ConfirmIncident(ctx context.Context, db db.DbDetails, proto *pogo.StartIncidentOutProto) string { + incident, unlock, err := getOrCreateIncidentRecord(ctx, db, proto.Incident.IncidentId, proto.Incident.FortId) + if err != nil { + return fmt.Sprintf("getOrCreateIncidentRecord: %s", err) + } + defer unlock() + + if incident.newRecord { + log.Debugf("Confirming incident before it was saved: %s", proto.Incident.IncidentId) + } + incident.updateFromStartIncidentOut(proto) + + saveIncidentRecord(ctx, db, incident) + return "" +} diff --git a/decoder/incident_state.go b/decoder/incident_state.go new file mode 100644 index 00000000..641bc06b --- /dev/null +++ b/decoder/incident_state.go @@ -0,0 +1,240 @@ +package decoder + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/webhooks" +) + +func loadIncidentFromDatabase(ctx context.Context, db db.DbDetails, incidentId string, incident *Incident) error { + err := db.GeneralDb.GetContext(ctx, incident, + "SELECT id, pokestop_id, start, expiration, display_type, style, `character`, updated, confirmed, slot_1_pokemon_id, slot_1_form, slot_2_pokemon_id, slot_2_form, slot_3_pokemon_id, slot_3_form "+ + "FROM incident WHERE incident.id = ?", incidentId) + statsCollector.IncDbQuery("select incident", err) + return err +} + +// peekIncidentRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func peekIncidentRecord(incidentId string) (*Incident, func(), error) { + if item := incidentCache.Get(incidentId); item != nil { + incident := item.Value() + incident.Lock() + return incident, func() { incident.Unlock() }, nil + } + return nil, nil, nil +} + +// getIncidentRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getIncidentRecordReadOnly(ctx context.Context, db db.DbDetails, incidentId string) (*Incident, func(), error) { + // Check cache first + if item := incidentCache.Get(incidentId); item != nil { + incident := item.Value() + incident.Lock() + return incident, func() { incident.Unlock() }, nil + } + + dbIncident := Incident{} + err := loadIncidentFromDatabase(ctx, db, incidentId, &dbIncident) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbIncident.ClearDirty() + + // Atomically cache the loaded Incident - if another goroutine raced us, + // we'll get their Incident and use that instead (ensuring same mutex) + existingIncident, _ := incidentCache.GetOrSetFunc(incidentId, func() *Incident { + return &dbIncident + }) + + incident := existingIncident.Value() + incident.Lock() + return incident, func() { incident.Unlock() }, nil +} + +// getIncidentRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Caller MUST call returned unlock function if non-nil. +func getIncidentRecordForUpdate(ctx context.Context, db db.DbDetails, incidentId string) (*Incident, func(), error) { + incident, unlock, err := getIncidentRecordReadOnly(ctx, db, incidentId) + if err != nil || incident == nil { + return nil, nil, err + } + incident.snapshotOldValues() + return incident, unlock, nil +} + +// getOrCreateIncidentRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreateIncidentRecord(ctx context.Context, db db.DbDetails, incidentId string, pokestopId string) (*Incident, func(), error) { + // Create new Incident atomically - function only called if key doesn't exist + incidentItem, _ := incidentCache.GetOrSetFunc(incidentId, func() *Incident { + return &Incident{Id: incidentId, PokestopId: pokestopId, newRecord: true} + }) + + incident := incidentItem.Value() + incident.Lock() + + if incident.newRecord { + // We should attempt to load from database + err := loadIncidentFromDatabase(ctx, db, incidentId, incident) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + incident.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + incident.newRecord = false + incident.ClearDirty() + } + } + + incident.snapshotOldValues() + return incident, func() { incident.Unlock() }, nil +} + +func saveIncidentRecord(ctx context.Context, db db.DbDetails, incident *Incident) { + // Skip save if not dirty and not new + if !incident.IsDirty() && !incident.IsNewRecord() { + return + } + + incident.SetUpdated(time.Now().Unix()) + + if incident.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Incident", incident.Id, incident.changedFields) + } + res, err := db.GeneralDb.NamedExec("INSERT INTO incident (id, pokestop_id, start, expiration, display_type, style, `character`, updated, confirmed, slot_1_pokemon_id, slot_1_form, slot_2_pokemon_id, slot_2_form, slot_3_pokemon_id, slot_3_form) "+ + "VALUES (:id, :pokestop_id, :start, :expiration, :display_type, :style, :character, :updated, :confirmed, :slot_1_pokemon_id, :slot_1_form, :slot_2_pokemon_id, :slot_2_form, :slot_3_pokemon_id, :slot_3_form)", incident) + + if err != nil { + log.Errorf("insert incident: %s", err) + return + } + statsCollector.IncDbQuery("insert incident", err) + _, _ = res, err + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Incident", incident.Id, incident.changedFields) + } + res, err := db.GeneralDb.NamedExec("UPDATE incident SET "+ + "start = :start, "+ + "expiration = :expiration, "+ + "display_type = :display_type, "+ + "style = :style, "+ + "`character` = :character, "+ + "updated = :updated, "+ + "confirmed = :confirmed, "+ + "slot_1_pokemon_id = :slot_1_pokemon_id, "+ + "slot_1_form = :slot_1_form, "+ + "slot_2_pokemon_id = :slot_2_pokemon_id, "+ + "slot_2_form = :slot_2_form, "+ + "slot_3_pokemon_id = :slot_3_pokemon_id, "+ + "slot_3_form = :slot_3_form "+ + "WHERE id = :id", incident, + ) + statsCollector.IncDbQuery("update incident", err) + if err != nil { + log.Errorf("Update incident %s", err) + } + _, _ = res, err + } + + createIncidentWebhooks(ctx, db, incident) + + var stopLat, stopLon float64 + stop, unlock, _ := getPokestopRecordReadOnly(ctx, db, incident.PokestopId) + if stop != nil { + stopLat, stopLon = stop.Lat, stop.Lon + unlock() + } + + areas := MatchStatsGeofence(stopLat, stopLon) + updateIncidentStats(incident, areas) + + incident.ClearDirty() + if incident.IsNewRecord() { + incident.newRecord = false + incidentCache.Set(incident.Id, incident, ttlcache.DefaultTTL) + } +} + +func createIncidentWebhooks(ctx context.Context, db db.DbDetails, incident *Incident) { + old := &incident.oldValues + isNew := incident.IsNewRecord() + + if isNew || (old.ExpirationTime != incident.ExpirationTime || old.Character != incident.Character || old.Confirmed != incident.Confirmed || old.Slot1PokemonId != incident.Slot1PokemonId) { + var pokestopName, stopUrl string + var stopLat, stopLon float64 + var stopEnabled bool + stop, unlock, _ := getPokestopRecordReadOnly(ctx, db, incident.PokestopId) + if stop != nil { + pokestopName = stop.Name.ValueOrZero() + stopLat, stopLon = stop.Lat, stop.Lon + stopUrl = stop.Url.ValueOrZero() + stopEnabled = stop.Enabled.ValueOrZero() + unlock() + } + if pokestopName == "" { + pokestopName = "Unknown" + } + + var lineup []webhookLineup + if incident.Slot1PokemonId.Valid { + lineup = []webhookLineup{ + { + Slot: 1, + PokemonId: incident.Slot1PokemonId, + Form: incident.Slot1Form, + }, + { + Slot: 2, + PokemonId: incident.Slot2PokemonId, + Form: incident.Slot2Form, + }, + { + Slot: 3, + PokemonId: incident.Slot3PokemonId, + Form: incident.Slot3Form, + }, + } + } + + incidentHook := IncidentWebhook{ + Id: incident.Id, + PokestopId: incident.PokestopId, + Latitude: stopLat, + Longitude: stopLon, + PokestopName: pokestopName, + Url: stopUrl, + Enabled: stopEnabled, + Start: incident.StartTime, + IncidentExpireTimestamp: incident.ExpirationTime, + Expiration: incident.ExpirationTime, + DisplayType: incident.DisplayType, + Style: incident.Style, + GruntType: incident.Character, + Character: incident.Character, + Updated: incident.Updated, + Confirmed: incident.Confirmed, + Lineup: lineup, + } + + areas := MatchStatsGeofence(stopLat, stopLon) + webhooksSender.AddMessage(webhooks.Invasion, incidentHook, areas) + statsCollector.UpdateIncidentCount(areas) + } +} diff --git a/decoder/main.go b/decoder/main.go index 42ec4c1b..3fcb043a 100644 --- a/decoder/main.go +++ b/decoder/main.go @@ -1,22 +1,17 @@ package decoder import ( - "context" "fmt" "math" "runtime" "time" - "golbat/intstripedmutex" - "github.com/UnownHash/gohbem" + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" - stripedmutex "github.com/nmvalera/striped-mutex" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" "golbat/config" - "golbat/db" "golbat/geo" "golbat/pogo" "golbat/stats_collector" @@ -58,30 +53,21 @@ type webhooksSenderInterface interface { var webhooksSender webhooksSenderInterface var statsCollector stats_collector.StatsCollector -var pokestopCache *ttlcache.Cache[string, Pokestop] -var gymCache *ttlcache.Cache[string, Gym] -var stationCache *ttlcache.Cache[string, Station] -var tappableCache *ttlcache.Cache[uint64, Tappable] -var weatherCache *ttlcache.Cache[int64, Weather] +var pokestopCache *ShardedCache[string, *Pokestop] +var gymCache *ttlcache.Cache[string, *Gym] +var stationCache *ttlcache.Cache[string, *Station] +var tappableCache *ttlcache.Cache[uint64, *Tappable] +var weatherCache *ttlcache.Cache[int64, *Weather] var weatherConsensusCache *ttlcache.Cache[int64, *WeatherConsensusState] -var s2CellCache *ttlcache.Cache[uint64, S2Cell] -var spawnpointCache *ttlcache.Cache[int64, Spawnpoint] -var pokemonCache []*ttlcache.Cache[uint64, Pokemon] -var incidentCache *ttlcache.Cache[string, Incident] -var playerCache *ttlcache.Cache[string, Player] -var routeCache *ttlcache.Cache[string, Route] +var s2CellCache *ttlcache.Cache[uint64, *S2Cell] +var spawnpointCache *ShardedCache[int64, *Spawnpoint] +var pokemonCache *ShardedCache[uint64, *Pokemon] +var incidentCache *ttlcache.Cache[string, *Incident] +var playerCache *ttlcache.Cache[string, *Player] +var routeCache *ttlcache.Cache[string, *Route] var diskEncounterCache *ttlcache.Cache[uint64, *pogo.DiskEncounterOutProto] var getMapFortsCache *ttlcache.Cache[string, *pogo.GetMapFortsOutProto_FortProto] -var gymStripedMutex = stripedmutex.New(128) -var pokestopStripedMutex = stripedmutex.New(128) -var stationStripedMutex = stripedmutex.New(128) -var tappableStripedMutex = intstripedmutex.New(563) -var incidentStripedMutex = stripedmutex.New(128) -var pokemonStripedMutex = intstripedmutex.New(1103) -var weatherStripedMutex = intstripedmutex.New(157) -var routeStripedMutex = stripedmutex.New(128) - var ProactiveIVSwitchSem chan bool var ohbem *gohbem.Ohbem @@ -92,7 +78,7 @@ func init() { } func InitProactiveIVSwitchSem() { - ProactiveIVSwitchSem = make(chan bool, config.Config.MaxConcurrentProactiveIVSwitch) + ProactiveIVSwitchSem = make(chan bool, config.Config.Tuning.MaxConcurrentProactiveIVSwitch) } type gohbemLogger struct{} @@ -101,45 +87,31 @@ func (cl *gohbemLogger) Print(message string) { log.Info("Gohbem - ", message) } -func getPokemonCache(key uint64) *ttlcache.Cache[uint64, Pokemon] { - return pokemonCache[key%uint64(len(pokemonCache))] -} - -func setPokemonCache(key uint64, value Pokemon, ttl time.Duration) { - getPokemonCache(key).Set(key, value, ttl) -} - -func getPokemonFromCache(key uint64) *ttlcache.Item[uint64, Pokemon] { - return getPokemonCache(key).Get(key) -} - -func deletePokemonFromCache(key uint64) { - getPokemonCache(key).Delete(key) -} - func initDataCache() { - pokestopCache = ttlcache.New[string, Pokestop]( - ttlcache.WithTTL[string, Pokestop](60 * time.Minute), - ) - go pokestopCache.Start() - - gymCache = ttlcache.New[string, Gym]( - ttlcache.WithTTL[string, Gym](60 * time.Minute), + // Sharded caches for high-concurrency tables + pokestopCache = NewShardedCache(ShardedCacheConfig[string, *Pokestop]{ + NumShards: runtime.NumCPU(), + TTL: 60 * time.Minute, + KeyToShard: StringKeyToShard, + }) + + gymCache = ttlcache.New[string, *Gym]( + ttlcache.WithTTL[string, *Gym](60 * time.Minute), ) go gymCache.Start() - stationCache = ttlcache.New[string, Station]( - ttlcache.WithTTL[string, Station](60 * time.Minute), + stationCache = ttlcache.New[string, *Station]( + ttlcache.WithTTL[string, *Station](60 * time.Minute), ) go stationCache.Start() - tappableCache = ttlcache.New[uint64, Tappable]( - ttlcache.WithTTL[uint64, Tappable](60 * time.Minute), + tappableCache = ttlcache.New[uint64, *Tappable]( + ttlcache.WithTTL[uint64, *Tappable](60 * time.Minute), ) go tappableCache.Start() - weatherCache = ttlcache.New[int64, Weather]( - ttlcache.WithTTL[int64, Weather](60 * time.Minute), + weatherCache = ttlcache.New[int64, *Weather]( + ttlcache.WithTTL[int64, *Weather](60 * time.Minute), ) go weatherCache.Start() @@ -148,36 +120,35 @@ func initDataCache() { ) go weatherConsensusCache.Start() - s2CellCache = ttlcache.New[uint64, S2Cell]( - ttlcache.WithTTL[uint64, S2Cell](60 * time.Minute), + s2CellCache = ttlcache.New[uint64, *S2Cell]( + ttlcache.WithTTL[uint64, *S2Cell](60 * time.Minute), ) go s2CellCache.Start() - spawnpointCache = ttlcache.New[int64, Spawnpoint]( - ttlcache.WithTTL[int64, Spawnpoint](60 * time.Minute), - ) - go spawnpointCache.Start() - - // pokemon is the most active table. Use an array of caches to increase concurrency for querying ttlcache, which places a global lock for each Get/Set operation - // Initialize pokemon cache array: by picking it to be nproc, we should expect ~nproc*(1-1/e) ~ 63% concurrency - pokemonCache = make([]*ttlcache.Cache[uint64, Pokemon], runtime.NumCPU()) - for i := 0; i < len(pokemonCache); i++ { - pokemonCache[i] = ttlcache.New[uint64, Pokemon]( - ttlcache.WithTTL[uint64, Pokemon](60*time.Minute), - ttlcache.WithDisableTouchOnHit[uint64, Pokemon](), // Pokemon will last 60 mins from when we first see them not last see them - ) - go pokemonCache[i].Start() - } + spawnpointCache = NewShardedCache(ShardedCacheConfig[int64, *Spawnpoint]{ + NumShards: runtime.NumCPU(), + TTL: 60 * time.Minute, + KeyToShard: Int64KeyToShard, + }) + + // Pokemon cache: sharded for high concurrency + // By picking NumShards to be nproc, we should expect ~nproc*(1-1/e) ~ 63% concurrency + pokemonCache = NewShardedCache(ShardedCacheConfig[uint64, *Pokemon]{ + NumShards: runtime.NumCPU(), + TTL: 60 * time.Minute, + KeyToShard: Uint64KeyToShard, + DisableTouchOnHit: true, // Pokemon will last 60 mins from when we first see them not last see them + }) initPokemonRtree() initFortRtree() - incidentCache = ttlcache.New[string, Incident]( - ttlcache.WithTTL[string, Incident](60 * time.Minute), + incidentCache = ttlcache.New[string, *Incident]( + ttlcache.WithTTL[string, *Incident](60 * time.Minute), ) go incidentCache.Start() - playerCache = ttlcache.New[string, Player]( - ttlcache.WithTTL[string, Player](60 * time.Minute), + playerCache = ttlcache.New[string, *Player]( + ttlcache.WithTTL[string, *Player](60 * time.Minute), ) go playerCache.Start() @@ -193,8 +164,8 @@ func initDataCache() { ) go getMapFortsCache.Start() - routeCache = ttlcache.New[string, Route]( - ttlcache.WithTTL[string, Route](60 * time.Minute), + routeCache = ttlcache.New[string, *Route]( + ttlcache.WithTTL[string, *Route](60 * time.Minute), ) go routeCache.Start() } @@ -276,304 +247,18 @@ func nullFloatAlmostEqual(a, b null.Float, tolerance float64) bool { } } -func UpdateFortBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, p []RawFortData) { - // Logic is: - // 1. Filter out pokestops that are unchanged (last modified time) - // 2. Fetch current stops from database - // 3. Generate batch of inserts as needed (with on duplicate saveGymRecord) - - //var stopsToModify []string - - for _, fort := range p { - fortId := fort.Data.FortId - if fort.Data.FortType == pogo.FortType_CHECKPOINT && scanParameters.ProcessPokestops { - pokestopMutex, _ := pokestopStripedMutex.GetLock(fortId) - - pokestopMutex.Lock() - pokestop, err := GetPokestopRecord(ctx, db, fortId) // should check error - if err != nil { - log.Errorf("getPokestopRecord: %s", err) - pokestopMutex.Unlock() - continue - } - - isNewPokestop := pokestop == nil - if isNewPokestop { - pokestop = &Pokestop{} - } - pokestop.updatePokestopFromFort(fort.Data, fort.Cell, fort.Timestamp/1000) - - // If this is a new pokestop, check if it was converted from a gym and copy shared fields - if isNewPokestop { - gym, _ := GetGymRecord(ctx, db, fortId) - if gym != nil { - pokestop.copySharedFieldsFrom(gym) - } - } - - savePokestopRecord(ctx, db, pokestop) - - incidents := fort.Data.PokestopDisplays - if incidents == nil && fort.Data.PokestopDisplay != nil { - incidents = []*pogo.PokestopIncidentDisplayProto{fort.Data.PokestopDisplay} - } - - if incidents != nil { - for _, incidentProto := range incidents { - incidentMutex, _ := incidentStripedMutex.GetLock(incidentProto.IncidentId) - - incidentMutex.Lock() - incident, err := getIncidentRecord(ctx, db, incidentProto.IncidentId) - if err != nil { - log.Errorf("getIncident: %s", err) - incidentMutex.Unlock() - continue - } - if incident == nil { - incident = &Incident{ - PokestopId: fortId, - } - } - incident.updateFromPokestopIncidentDisplay(incidentProto) - saveIncidentRecord(ctx, db, incident) - - incidentMutex.Unlock() - } - } - pokestopMutex.Unlock() - } - - if fort.Data.FortType == pogo.FortType_GYM && scanParameters.ProcessGyms { - gymMutex, _ := gymStripedMutex.GetLock(fortId) - - gymMutex.Lock() - gym, err := GetGymRecord(ctx, db, fortId) - if err != nil { - log.Errorf("GetGymRecord: %s", err) - gymMutex.Unlock() - continue - } - - isNewGym := gym == nil - if isNewGym { - gym = &Gym{} - } - - gym.updateGymFromFort(fort.Data, fort.Cell) - - // If this is a new gym, check if it was converted from a pokestop and copy shared fields - if isNewGym { - pokestop, _ := GetPokestopRecord(ctx, db, fortId) - if pokestop != nil { - gym.copySharedFieldsFrom(pokestop) - } - } - - saveGymRecord(ctx, db, gym) - gymMutex.Unlock() - } - } -} - -func UpdateStationBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, p []RawStationData) { - for _, stationProto := range p { - stationId := stationProto.Data.Id - stationMutex, _ := stationStripedMutex.GetLock(stationId) - stationMutex.Lock() - station, err := getStationRecord(ctx, db, stationId) - if err != nil { - log.Errorf("getStationRecord: %s", err) - stationMutex.Unlock() - continue - } - if station == nil { - station = &Station{} - } - station.updateFromStationProto(stationProto.Data, stationProto.Cell) - saveStationRecord(ctx, db, station) - stationMutex.Unlock() - } -} - -func UpdatePokemonBatch(ctx context.Context, db db.DbDetails, scanParameters ScanParameters, wildPokemonList []RawWildPokemonData, nearbyPokemonList []RawNearbyPokemonData, mapPokemonList []RawMapPokemonData, weather []*pogo.ClientWeatherProto, username string) { - weatherLookup := make(map[int64]pogo.GameplayWeatherProto_WeatherCondition) - for _, weatherProto := range weather { - weatherLookup[weatherProto.S2CellId] = weatherProto.GameplayWeather.GameplayCondition - } - - for _, wild := range wildPokemonList { - encounterId := wild.Data.EncounterId - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - - spawnpointUpdateFromWild(ctx, db, wild.Data, wild.Timestamp) - - if scanParameters.ProcessWild { - pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId) - if err != nil { - log.Errorf("getOrCreatePokemonRecord: %s", err) - } else { - updateTime := wild.Timestamp / 1000 - if pokemon.isNewRecord() || pokemon.wildSignificantUpdate(wild.Data, updateTime) { - go func(wildPokemon *pogo.WildPokemonProto, cellId int64, timestampMs int64) { - time.Sleep(15 * time.Second) - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - defer pokemonMutex.Unlock() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - if pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId); err != nil { - log.Errorf("getOrCreatePokemonRecord: %s", err) - } else { - // Update if there is still a change required & this update is the most recent - if pokemon.wildSignificantUpdate(wildPokemon, updateTime) && pokemon.Updated.ValueOrZero() < updateTime { - log.Debugf("DELAYED UPDATE: Updating pokemon %d from wild", encounterId) - - pokemon.updateFromWild(ctx, db, wildPokemon, cellId, weatherLookup, timestampMs, username) - savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, updateTime) - } - } - }(wild.Data, int64(wild.Cell), wild.Timestamp) - } - } - } - pokemonMutex.Unlock() - } - - if scanParameters.ProcessNearby { - for _, nearby := range nearbyPokemonList { - encounterId := nearby.Data.EncounterId - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - - pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId) - if err != nil { - log.Printf("getOrCreatePokemonRecord: %s", err) - } else { - pokemon.updateFromNearby(ctx, db, nearby.Data, int64(nearby.Cell), weatherLookup, nearby.Timestamp, username) - savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, nearby.Timestamp/1000) - } - - pokemonMutex.Unlock() - } - } - - for _, mapPokemon := range mapPokemonList { - encounterId := mapPokemon.Data.EncounterId - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - - pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId) - if err != nil { - log.Printf("getOrCreatePokemonRecord: %s", err) - } else { - pokemon.updateFromMap(ctx, db, mapPokemon.Data, int64(mapPokemon.Cell), weatherLookup, mapPokemon.Timestamp, username) - storedDiskEncounter := diskEncounterCache.Get(encounterId) - if storedDiskEncounter != nil { - diskEncounter := storedDiskEncounter.Value() - diskEncounterCache.Delete(encounterId) - pokemon.updatePokemonFromDiskEncounterProto(ctx, db, diskEncounter, username) - //log.Infof("Processed stored disk encounter") - } - savePokemonRecordAsAtTime(ctx, db, pokemon, false, true, true, mapPokemon.Timestamp/1000) - } - pokemonMutex.Unlock() - } -} - -func UpdateClientWeatherBatch(ctx context.Context, db db.DbDetails, p []*pogo.ClientWeatherProto, timestampMs int64, account string) (updates []WeatherUpdate) { - hourKey := timestampMs / time.Hour.Milliseconds() - for _, weatherProto := range p { - weatherMutex, _ := weatherStripedMutex.GetLock(uint64(weatherProto.S2CellId)) - weatherMutex.Lock() - - weather, err := getWeatherRecord(ctx, db, weatherProto.S2CellId) - if err != nil { - log.Printf("getWeatherRecord: %s", err) - } else if weather == nil || timestampMs >= weather.UpdatedMs { - state := getWeatherConsensusState(weatherProto.S2CellId, hourKey) - if state != nil { - publish, publishProto := state.applyObservation(hourKey, account, weatherProto) - if publish { - if publishProto == nil { - publishProto = weatherProto - } - if weather == nil { - weather = &Weather{} - } - weather.UpdatedMs = timestampMs - oldWeather := weather.updateWeatherFromClientWeatherProto(publishProto) - saveWeatherRecord(ctx, db, weather) - if oldWeather != weather.GameplayCondition { - updates = append(updates, WeatherUpdate{ - S2CellId: publishProto.S2CellId, - NewWeather: int32(publishProto.GetGameplayWeather().GetGameplayCondition()), - }) - } - } - } - } - - weatherMutex.Unlock() - } - return updates -} - -func UpdateClientMapS2CellBatch(ctx context.Context, db db.DbDetails, cellIds []uint64) { - saveS2CellRecords(ctx, db, cellIds) +// Ptrable is an interface for any type that has a Ptr() method returning *T +// specifically these are the null objects +type Ptrable[T any] interface { + Ptr() *T } -func UpdateIncidentLineup(ctx context.Context, db db.DbDetails, protoReq *pogo.OpenInvasionCombatSessionProto, protoRes *pogo.OpenInvasionCombatSessionOutProto) string { - incidentMutex, _ := incidentStripedMutex.GetLock(protoReq.IncidentLookup.IncidentId) - - incidentMutex.Lock() - incident, err := getIncidentRecord(ctx, db, protoReq.IncidentLookup.IncidentId) - if err != nil { - incidentMutex.Unlock() - return fmt.Sprintf("getIncident: %s", err) - } - if incident == nil { - log.Debugf("Updating lineup before it was saved: %s", protoReq.IncidentLookup.IncidentId) - incident = &Incident{ - Id: protoReq.IncidentLookup.IncidentId, - PokestopId: protoReq.IncidentLookup.FortId, - } +// FormatNull returns "NULL" if the nullable value is not valid, otherwise formats the value +func FormatNull[T any](n Ptrable[T]) string { + if ptr := n.Ptr(); ptr != nil { + return fmt.Sprintf("%v", *ptr) } - incident.updateFromOpenInvasionCombatSessionOut(protoRes) - - saveIncidentRecord(ctx, db, incident) - incidentMutex.Unlock() - return "" -} - -func ConfirmIncident(ctx context.Context, db db.DbDetails, proto *pogo.StartIncidentOutProto) string { - incidentMutex, _ := incidentStripedMutex.GetLock(proto.Incident.IncidentId) - - incidentMutex.Lock() - incident, err := getIncidentRecord(ctx, db, proto.Incident.IncidentId) - if err != nil { - incidentMutex.Unlock() - return fmt.Sprintf("getIncident: %s", err) - } - if incident == nil { - log.Debugf("Confirming incident before it was saved: %s", proto.Incident.IncidentId) - incident = &Incident{ - Id: proto.Incident.IncidentId, - PokestopId: proto.Incident.FortId, - } - } - incident.updateFromStartIncidentOut(proto) - - if incident == nil { - incidentMutex.Unlock() - // I only saw this once during testing but I couldn't reproduce it so just in case - return "Unable to process incident" - } - saveIncidentRecord(ctx, db, incident) - incidentMutex.Unlock() - return "" + return "NULL" } func SetWebhooksSender(whSender webhooksSenderInterface) { @@ -583,3 +268,13 @@ func SetWebhooksSender(whSender webhooksSenderInterface) { func SetStatsCollector(collector stats_collector.StatsCollector) { statsCollector = collector } + +// GetUpdateThreshold returns the number of seconds that should be used as a +// debounce/last-seen threshold. Pass the default seconds for normal operation +// If ReduceUpdates is enabled in the loaded config.Config, this returns 43200 (12 hours). +func GetUpdateThreshold(defaultSeconds int64) int64 { + if config.Config.Tuning.ReduceUpdates { + return 43200 // 12 hours + } + return defaultSeconds +} diff --git a/decoder/pending_pokemon.go b/decoder/pending_pokemon.go new file mode 100644 index 00000000..39042db3 --- /dev/null +++ b/decoder/pending_pokemon.go @@ -0,0 +1,168 @@ +package decoder + +import ( + "context" + "sync" + "time" + + "golbat/db" + "golbat/pogo" + + log "github.com/sirupsen/logrus" +) + +// PendingPokemon stores wild pokemon data awaiting a potential encounter +type PendingPokemon struct { + EncounterId uint64 + WildPokemon *pogo.WildPokemonProto + CellId int64 + TimestampMs int64 + UpdateTime int64 + WeatherLookup map[int64]pogo.GameplayWeatherProto_WeatherCondition + Username string + ReceivedAt time.Time +} + +// PokemonPendingQueue manages pokemon awaiting encounter data +type PokemonPendingQueue struct { + mu sync.RWMutex + pending map[uint64]*PendingPokemon + timeout time.Duration +} + +// NewPokemonPendingQueue creates a new pending queue with the specified timeout +func NewPokemonPendingQueue(timeout time.Duration) *PokemonPendingQueue { + return &PokemonPendingQueue{ + pending: make(map[uint64]*PendingPokemon), + timeout: timeout, + } +} + +// AddPending stores a wild pokemon awaiting encounter data. +// Returns true if the pokemon was added, false if it already exists. +func (q *PokemonPendingQueue) AddPending(p *PendingPokemon) bool { + q.mu.Lock() + defer q.mu.Unlock() + + // Only add if not already present (first sighting wins) + if _, exists := q.pending[p.EncounterId]; exists { + return false + } + + p.ReceivedAt = time.Now() + q.pending[p.EncounterId] = p + return true +} + +// TryComplete attempts to retrieve and remove a pending pokemon for an encounter. +// Returns the pending pokemon and true if found, nil and false otherwise. +func (q *PokemonPendingQueue) TryComplete(encounterId uint64) (*PendingPokemon, bool) { + q.mu.Lock() + defer q.mu.Unlock() + + p, exists := q.pending[encounterId] + if exists { + delete(q.pending, encounterId) + } + return p, exists +} + +// Remove removes a pending pokemon without processing it. +func (q *PokemonPendingQueue) Remove(encounterId uint64) { + q.mu.Lock() + delete(q.pending, encounterId) + q.mu.Unlock() +} + +// Size returns the current number of pending pokemon +func (q *PokemonPendingQueue) Size() int { + q.mu.RLock() + defer q.mu.RUnlock() + return len(q.pending) +} + +// collectExpired removes and returns all entries older than timeout +func (q *PokemonPendingQueue) collectExpired() []*PendingPokemon { + cutoff := time.Now().Add(-q.timeout) + + q.mu.Lock() + defer q.mu.Unlock() + + var expired []*PendingPokemon + for id, p := range q.pending { + if p.ReceivedAt.Before(cutoff) { + expired = append(expired, p) + delete(q.pending, id) + } + } + + return expired +} + +// StartSweeper starts a background goroutine that processes expired entries +func (q *PokemonPendingQueue) StartSweeper(ctx context.Context, interval time.Duration, dbDetails db.DbDetails) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info("Pokemon pending queue sweeper stopped") + return + case <-ticker.C: + expired := q.collectExpired() + if len(expired) > 0 { + log.Debugf("Processing %d expired pending pokemon", len(expired)) + q.processExpired(ctx, dbDetails, expired) + } + } + } + }() +} + +// processExpired handles pokemon that didn't receive an encounter within the timeout +func (q *PokemonPendingQueue) processExpired(ctx context.Context, dbDetails db.DbDetails, expired []*PendingPokemon) { + for _, p := range expired { + // Check for shutdown signal between iterations + if ctx.Err() != nil { + log.Debug("Context cancelled, stopping expired pokemon processing") + return + } + + processCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + + pokemon, unlock, err := getOrCreatePokemonRecord(processCtx, dbDetails, p.EncounterId) + if err != nil { + log.Errorf("getOrCreatePokemonRecord in sweeper: %s", err) + cancel() + continue + } + + // Update if there is still a change required & this update is the most recent + if pokemon.wildSignificantUpdate(p.WildPokemon, p.UpdateTime) && pokemon.Updated.ValueOrZero() < p.UpdateTime { + log.Debugf("DELAYED UPDATE: Updating pokemon %d from wild (sweeper)", p.EncounterId) + + pokemon.updateFromWild(processCtx, dbDetails, p.WildPokemon, p.CellId, p.WeatherLookup, p.TimestampMs, p.Username) + savePokemonRecordAsAtTime(processCtx, dbDetails, pokemon, false, true, true, p.UpdateTime) + } + + unlock() + cancel() + } +} + +// Global pending queue instance +var pokemonPendingQueue *PokemonPendingQueue + +// InitPokemonPendingQueue initializes the global pending queue +func InitPokemonPendingQueue(ctx context.Context, dbDetails db.DbDetails, timeout time.Duration, sweepInterval time.Duration) { + pokemonPendingQueue = NewPokemonPendingQueue(timeout) + pokemonPendingQueue.StartSweeper(ctx, sweepInterval, dbDetails) + log.Infof("Pokemon pending queue started with %v timeout and %v sweep interval", timeout, sweepInterval) +} + +// GetPokemonPendingQueue returns the global pending queue instance +func GetPokemonPendingQueue() *PokemonPendingQueue { + return pokemonPendingQueue +} diff --git a/decoder/player.go b/decoder/player.go index 6c359f1f..0bd20785 100644 --- a/decoder/player.go +++ b/decoder/player.go @@ -2,6 +2,7 @@ package decoder import ( "database/sql" + "fmt" "reflect" "strconv" "time" @@ -9,13 +10,13 @@ import ( "golbat/db" "golbat/pogo" + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) // Player struct. Name is the primary key. -// REMINDER! Keep hasChangesPlayer updated after making changes +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Player struct { // Name is the primary key Name string `db:"name"` @@ -102,6 +103,861 @@ type Player struct { CaughtDragon null.Int `db:"caught_dragon"` CaughtDark null.Int `db:"caught_dark"` CaughtFairy null.Int `db:"caught_fairy"` + + dirty bool `db:"-" json:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-" json:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-" json:"-"` // Track which fields changed (only when dbDebugEnabled) +} + +// IsDirty returns true if any field has been modified +func (p *Player) IsDirty() bool { + return p.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (p *Player) ClearDirty() { + p.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (p *Player) IsNewRecord() bool { + return p.newRecord +} + +// setFieldDirty marks the dirty flag. Used by reflection-based updates. +func (p *Player) setFieldDirty() { + p.dirty = true +} + +// --- Set methods with dirty tracking --- + +func (p *Player) SetFriendshipId(v null.String) { + if p.FriendshipId != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("FriendshipId:%s->%s", FormatNull(p.FriendshipId), FormatNull(v))) + } + p.FriendshipId = v + p.dirty = true + } +} + +func (p *Player) SetFriendCode(v null.String) { + if p.FriendCode != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("FriendCode:%s->%s", FormatNull(p.FriendCode), FormatNull(v))) + } + p.FriendCode = v + p.dirty = true + } +} + +func (p *Player) SetTeam(v null.Int) { + if p.Team != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Team:%s->%s", FormatNull(p.Team), FormatNull(v))) + } + p.Team = v + p.dirty = true + } +} + +func (p *Player) SetLevel(v null.Int) { + if p.Level != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Level:%s->%s", FormatNull(p.Level), FormatNull(v))) + } + p.Level = v + p.dirty = true + } +} + +func (p *Player) SetXp(v null.Int) { + if p.Xp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Xp:%s->%s", FormatNull(p.Xp), FormatNull(v))) + } + p.Xp = v + p.dirty = true + } +} + +func (p *Player) SetBattlesWon(v null.Int) { + if p.BattlesWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("BattlesWon:%s->%s", FormatNull(p.BattlesWon), FormatNull(v))) + } + p.BattlesWon = v + p.dirty = true + } +} + +func (p *Player) SetKmWalked(v null.Float) { + if !nullFloatAlmostEqual(p.KmWalked, v, 0.001) { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("KmWalked:%s->%s", FormatNull(p.KmWalked), FormatNull(v))) + } + p.KmWalked = v + p.dirty = true + } +} + +func (p *Player) SetCaughtPokemon(v null.Int) { + if p.CaughtPokemon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtPokemon:%s->%s", FormatNull(p.CaughtPokemon), FormatNull(v))) + } + p.CaughtPokemon = v + p.dirty = true + } +} + +func (p *Player) SetGblRank(v null.Int) { + if p.GblRank != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("GblRank:%s->%s", FormatNull(p.GblRank), FormatNull(v))) + } + p.GblRank = v + p.dirty = true + } +} + +func (p *Player) SetGblRating(v null.Int) { + if p.GblRating != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("GblRating:%s->%s", FormatNull(p.GblRating), FormatNull(v))) + } + p.GblRating = v + p.dirty = true + } +} + +func (p *Player) SetEventBadges(v null.String) { + if p.EventBadges != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("EventBadges:%s->%s", FormatNull(p.EventBadges), FormatNull(v))) + } + p.EventBadges = v + p.dirty = true + } +} + +func (p *Player) SetStopsSpun(v null.Int) { + if p.StopsSpun != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("StopsSpun:%s->%s", FormatNull(p.StopsSpun), FormatNull(v))) + } + p.StopsSpun = v + p.dirty = true + } +} + +func (p *Player) SetEvolved(v null.Int) { + if p.Evolved != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Evolved:%s->%s", FormatNull(p.Evolved), FormatNull(v))) + } + p.Evolved = v + p.dirty = true + } +} + +func (p *Player) SetHatched(v null.Int) { + if p.Hatched != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Hatched:%s->%s", FormatNull(p.Hatched), FormatNull(v))) + } + p.Hatched = v + p.dirty = true + } +} + +func (p *Player) SetQuests(v null.Int) { + if p.Quests != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Quests:%s->%s", FormatNull(p.Quests), FormatNull(v))) + } + p.Quests = v + p.dirty = true + } +} + +func (p *Player) SetTrades(v null.Int) { + if p.Trades != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Trades:%s->%s", FormatNull(p.Trades), FormatNull(v))) + } + p.Trades = v + p.dirty = true + } +} + +func (p *Player) SetPhotobombs(v null.Int) { + if p.Photobombs != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Photobombs:%s->%s", FormatNull(p.Photobombs), FormatNull(v))) + } + p.Photobombs = v + p.dirty = true + } +} + +func (p *Player) SetPurified(v null.Int) { + if p.Purified != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Purified:%s->%s", FormatNull(p.Purified), FormatNull(v))) + } + p.Purified = v + p.dirty = true + } +} + +func (p *Player) SetGruntsDefeated(v null.Int) { + if p.GruntsDefeated != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("GruntsDefeated:%s->%s", FormatNull(p.GruntsDefeated), FormatNull(v))) + } + p.GruntsDefeated = v + p.dirty = true + } +} + +func (p *Player) SetGymBattlesWon(v null.Int) { + if p.GymBattlesWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("GymBattlesWon:%s->%s", FormatNull(p.GymBattlesWon), FormatNull(v))) + } + p.GymBattlesWon = v + p.dirty = true + } +} + +func (p *Player) SetNormalRaidsWon(v null.Int) { + if p.NormalRaidsWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("NormalRaidsWon:%s->%s", FormatNull(p.NormalRaidsWon), FormatNull(v))) + } + p.NormalRaidsWon = v + p.dirty = true + } +} + +func (p *Player) SetLegendaryRaidsWon(v null.Int) { + if p.LegendaryRaidsWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LegendaryRaidsWon:%s->%s", FormatNull(p.LegendaryRaidsWon), FormatNull(v))) + } + p.LegendaryRaidsWon = v + p.dirty = true + } +} + +func (p *Player) SetTrainingsWon(v null.Int) { + if p.TrainingsWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("TrainingsWon:%s->%s", FormatNull(p.TrainingsWon), FormatNull(v))) + } + p.TrainingsWon = v + p.dirty = true + } +} + +func (p *Player) SetBerriesFed(v null.Int) { + if p.BerriesFed != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("BerriesFed:%s->%s", FormatNull(p.BerriesFed), FormatNull(v))) + } + p.BerriesFed = v + p.dirty = true + } +} + +func (p *Player) SetHoursDefended(v null.Int) { + if p.HoursDefended != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("HoursDefended:%s->%s", FormatNull(p.HoursDefended), FormatNull(v))) + } + p.HoursDefended = v + p.dirty = true + } +} + +func (p *Player) SetBestFriends(v null.Int) { + if p.BestFriends != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("BestFriends:%s->%s", FormatNull(p.BestFriends), FormatNull(v))) + } + p.BestFriends = v + p.dirty = true + } +} + +func (p *Player) SetBestBuddies(v null.Int) { + if p.BestBuddies != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("BestBuddies:%s->%s", FormatNull(p.BestBuddies), FormatNull(v))) + } + p.BestBuddies = v + p.dirty = true + } +} + +func (p *Player) SetGiovanniDefeated(v null.Int) { + if p.GiovanniDefeated != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("GiovanniDefeated:%s->%s", FormatNull(p.GiovanniDefeated), FormatNull(v))) + } + p.GiovanniDefeated = v + p.dirty = true + } +} + +func (p *Player) SetMegaEvos(v null.Int) { + if p.MegaEvos != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("MegaEvos:%s->%s", FormatNull(p.MegaEvos), FormatNull(v))) + } + p.MegaEvos = v + p.dirty = true + } +} + +func (p *Player) SetCollectionsDone(v null.Int) { + if p.CollectionsDone != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CollectionsDone:%s->%s", FormatNull(p.CollectionsDone), FormatNull(v))) + } + p.CollectionsDone = v + p.dirty = true + } +} + +func (p *Player) SetUniqueStopsSpun(v null.Int) { + if p.UniqueStopsSpun != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("UniqueStopsSpun:%s->%s", FormatNull(p.UniqueStopsSpun), FormatNull(v))) + } + p.UniqueStopsSpun = v + p.dirty = true + } +} + +func (p *Player) SetUniqueMegaEvos(v null.Int) { + if p.UniqueMegaEvos != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("UniqueMegaEvos:%s->%s", FormatNull(p.UniqueMegaEvos), FormatNull(v))) + } + p.UniqueMegaEvos = v + p.dirty = true + } +} + +func (p *Player) SetUniqueRaidBosses(v null.Int) { + if p.UniqueRaidBosses != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("UniqueRaidBosses:%s->%s", FormatNull(p.UniqueRaidBosses), FormatNull(v))) + } + p.UniqueRaidBosses = v + p.dirty = true + } +} + +func (p *Player) SetUniqueUnown(v null.Int) { + if p.UniqueUnown != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("UniqueUnown:%s->%s", FormatNull(p.UniqueUnown), FormatNull(v))) + } + p.UniqueUnown = v + p.dirty = true + } +} + +func (p *Player) SetSevenDayStreaks(v null.Int) { + if p.SevenDayStreaks != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("SevenDayStreaks:%s->%s", FormatNull(p.SevenDayStreaks), FormatNull(v))) + } + p.SevenDayStreaks = v + p.dirty = true + } +} + +func (p *Player) SetTradeKm(v null.Int) { + if p.TradeKm != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("TradeKm:%s->%s", FormatNull(p.TradeKm), FormatNull(v))) + } + p.TradeKm = v + p.dirty = true + } +} + +func (p *Player) SetRaidsWithFriends(v null.Int) { + if p.RaidsWithFriends != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("RaidsWithFriends:%s->%s", FormatNull(p.RaidsWithFriends), FormatNull(v))) + } + p.RaidsWithFriends = v + p.dirty = true + } +} + +func (p *Player) SetCaughtAtLure(v null.Int) { + if p.CaughtAtLure != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtAtLure:%s->%s", FormatNull(p.CaughtAtLure), FormatNull(v))) + } + p.CaughtAtLure = v + p.dirty = true + } +} + +func (p *Player) SetWayfarerAgreements(v null.Int) { + if p.WayfarerAgreements != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("WayfarerAgreements:%s->%s", FormatNull(p.WayfarerAgreements), FormatNull(v))) + } + p.WayfarerAgreements = v + p.dirty = true + } +} + +func (p *Player) SetTrainersReferred(v null.Int) { + if p.TrainersReferred != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("TrainersReferred:%s->%s", FormatNull(p.TrainersReferred), FormatNull(v))) + } + p.TrainersReferred = v + p.dirty = true + } +} + +func (p *Player) SetRaidAchievements(v null.Int) { + if p.RaidAchievements != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("RaidAchievements:%s->%s", FormatNull(p.RaidAchievements), FormatNull(v))) + } + p.RaidAchievements = v + p.dirty = true + } +} + +func (p *Player) SetXlKarps(v null.Int) { + if p.XlKarps != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("XlKarps:%s->%s", FormatNull(p.XlKarps), FormatNull(v))) + } + p.XlKarps = v + p.dirty = true + } +} + +func (p *Player) SetXsRats(v null.Int) { + if p.XsRats != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("XsRats:%s->%s", FormatNull(p.XsRats), FormatNull(v))) + } + p.XsRats = v + p.dirty = true + } +} + +func (p *Player) SetPikachuCaught(v null.Int) { + if p.PikachuCaught != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PikachuCaught:%s->%s", FormatNull(p.PikachuCaught), FormatNull(v))) + } + p.PikachuCaught = v + p.dirty = true + } +} + +func (p *Player) SetLeagueGreatWon(v null.Int) { + if p.LeagueGreatWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LeagueGreatWon:%s->%s", FormatNull(p.LeagueGreatWon), FormatNull(v))) + } + p.LeagueGreatWon = v + p.dirty = true + } +} + +func (p *Player) SetLeagueUltraWon(v null.Int) { + if p.LeagueUltraWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LeagueUltraWon:%s->%s", FormatNull(p.LeagueUltraWon), FormatNull(v))) + } + p.LeagueUltraWon = v + p.dirty = true + } +} + +func (p *Player) SetLeagueMasterWon(v null.Int) { + if p.LeagueMasterWon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LeagueMasterWon:%s->%s", FormatNull(p.LeagueMasterWon), FormatNull(v))) + } + p.LeagueMasterWon = v + p.dirty = true + } +} + +func (p *Player) SetTinyPokemonCaught(v null.Int) { + if p.TinyPokemonCaught != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("TinyPokemonCaught:%s->%s", FormatNull(p.TinyPokemonCaught), FormatNull(v))) + } + p.TinyPokemonCaught = v + p.dirty = true + } +} + +func (p *Player) SetJumboPokemonCaught(v null.Int) { + if p.JumboPokemonCaught != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("JumboPokemonCaught:%s->%s", FormatNull(p.JumboPokemonCaught), FormatNull(v))) + } + p.JumboPokemonCaught = v + p.dirty = true + } +} + +func (p *Player) SetVivillon(v null.Int) { + if p.Vivillon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Vivillon:%s->%s", FormatNull(p.Vivillon), FormatNull(v))) + } + p.Vivillon = v + p.dirty = true + } +} + +func (p *Player) SetMaxSizeFirstPlace(v null.Int) { + if p.MaxSizeFirstPlace != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("MaxSizeFirstPlace:%s->%s", FormatNull(p.MaxSizeFirstPlace), FormatNull(v))) + } + p.MaxSizeFirstPlace = v + p.dirty = true + } +} + +func (p *Player) SetTotalRoutePlay(v null.Int) { + if p.TotalRoutePlay != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("TotalRoutePlay:%s->%s", FormatNull(p.TotalRoutePlay), FormatNull(v))) + } + p.TotalRoutePlay = v + p.dirty = true + } +} + +func (p *Player) SetPartiesCompleted(v null.Int) { + if p.PartiesCompleted != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PartiesCompleted:%s->%s", FormatNull(p.PartiesCompleted), FormatNull(v))) + } + p.PartiesCompleted = v + p.dirty = true + } +} + +func (p *Player) SetEventCheckIns(v null.Int) { + if p.EventCheckIns != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("EventCheckIns:%s->%s", FormatNull(p.EventCheckIns), FormatNull(v))) + } + p.EventCheckIns = v + p.dirty = true + } +} +func (p *Player) SetDexGen1(v null.Int) { + if p.DexGen1 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen1:%s->%s", FormatNull(p.DexGen1), FormatNull(v))) + } + p.DexGen1 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen2(v null.Int) { + if p.DexGen2 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen2:%s->%s", FormatNull(p.DexGen2), FormatNull(v))) + } + p.DexGen2 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen3(v null.Int) { + if p.DexGen3 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen3:%s->%s", FormatNull(p.DexGen3), FormatNull(v))) + } + p.DexGen3 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen4(v null.Int) { + if p.DexGen4 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen4:%s->%s", FormatNull(p.DexGen4), FormatNull(v))) + } + p.DexGen4 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen5(v null.Int) { + if p.DexGen5 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen5:%s->%s", FormatNull(p.DexGen5), FormatNull(v))) + } + p.DexGen5 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen6(v null.Int) { + if p.DexGen6 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen6:%s->%s", FormatNull(p.DexGen6), FormatNull(v))) + } + p.DexGen6 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen7(v null.Int) { + if p.DexGen7 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen7:%s->%s", FormatNull(p.DexGen7), FormatNull(v))) + } + p.DexGen7 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen8(v null.Int) { + if p.DexGen8 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen8:%s->%s", FormatNull(p.DexGen8), FormatNull(v))) + } + p.DexGen8 = v + p.dirty = true + } +} + +func (p *Player) SetDexGen8A(v null.Int) { + if p.DexGen8A != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen8A:%s->%s", FormatNull(p.DexGen8A), FormatNull(v))) + } + p.DexGen8A = v + p.dirty = true + } +} + +func (p *Player) SetDexGen9(v null.Int) { + if p.DexGen9 != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("DexGen9:%s->%s", FormatNull(p.DexGen9), FormatNull(v))) + } + p.DexGen9 = v + p.dirty = true + } +} + +func (p *Player) SetCaughtNormal(v null.Int) { + if p.CaughtNormal != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtNormal:%s->%s", FormatNull(p.CaughtNormal), FormatNull(v))) + } + p.CaughtNormal = v + p.dirty = true + } +} + +func (p *Player) SetCaughtFighting(v null.Int) { + if p.CaughtFighting != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtFighting:%s->%s", FormatNull(p.CaughtFighting), FormatNull(v))) + } + p.CaughtFighting = v + p.dirty = true + } +} + +func (p *Player) SetCaughtFlying(v null.Int) { + if p.CaughtFlying != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtFlying:%s->%s", FormatNull(p.CaughtFlying), FormatNull(v))) + } + p.CaughtFlying = v + p.dirty = true + } +} + +func (p *Player) SetCaughtPoison(v null.Int) { + if p.CaughtPoison != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtPoison:%s->%s", FormatNull(p.CaughtPoison), FormatNull(v))) + } + p.CaughtPoison = v + p.dirty = true + } +} + +func (p *Player) SetCaughtGround(v null.Int) { + if p.CaughtGround != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtGround:%s->%s", FormatNull(p.CaughtGround), FormatNull(v))) + } + p.CaughtGround = v + p.dirty = true + } +} + +func (p *Player) SetCaughtRock(v null.Int) { + if p.CaughtRock != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtRock:%s->%s", FormatNull(p.CaughtRock), FormatNull(v))) + } + p.CaughtRock = v + p.dirty = true + } +} + +func (p *Player) SetCaughtBug(v null.Int) { + if p.CaughtBug != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtBug:%s->%s", FormatNull(p.CaughtBug), FormatNull(v))) + } + p.CaughtBug = v + p.dirty = true + } +} + +func (p *Player) SetCaughtGhost(v null.Int) { + if p.CaughtGhost != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtGhost:%s->%s", FormatNull(p.CaughtGhost), FormatNull(v))) + } + p.CaughtGhost = v + p.dirty = true + } +} + +func (p *Player) SetCaughtSteel(v null.Int) { + if p.CaughtSteel != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtSteel:%s->%s", FormatNull(p.CaughtSteel), FormatNull(v))) + } + p.CaughtSteel = v + p.dirty = true + } +} + +func (p *Player) SetCaughtFire(v null.Int) { + if p.CaughtFire != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtFire:%s->%s", FormatNull(p.CaughtFire), FormatNull(v))) + } + p.CaughtFire = v + p.dirty = true + } +} + +func (p *Player) SetCaughtWater(v null.Int) { + if p.CaughtWater != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtWater:%s->%s", FormatNull(p.CaughtWater), FormatNull(v))) + } + p.CaughtWater = v + p.dirty = true + } +} + +func (p *Player) SetCaughtGrass(v null.Int) { + if p.CaughtGrass != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtGrass:%s->%s", FormatNull(p.CaughtGrass), FormatNull(v))) + } + p.CaughtGrass = v + p.dirty = true + } +} + +func (p *Player) SetCaughtElectric(v null.Int) { + if p.CaughtElectric != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtElectric:%s->%s", FormatNull(p.CaughtElectric), FormatNull(v))) + } + p.CaughtElectric = v + p.dirty = true + } +} + +func (p *Player) SetCaughtPsychic(v null.Int) { + if p.CaughtPsychic != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtPsychic:%s->%s", FormatNull(p.CaughtPsychic), FormatNull(v))) + } + p.CaughtPsychic = v + p.dirty = true + } +} + +func (p *Player) SetCaughtIce(v null.Int) { + if p.CaughtIce != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtIce:%s->%s", FormatNull(p.CaughtIce), FormatNull(v))) + } + p.CaughtIce = v + p.dirty = true + } +} + +func (p *Player) SetCaughtDragon(v null.Int) { + if p.CaughtDragon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtDragon:%s->%s", FormatNull(p.CaughtDragon), FormatNull(v))) + } + p.CaughtDragon = v + p.dirty = true + } +} + +func (p *Player) SetCaughtDark(v null.Int) { + if p.CaughtDark != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtDark:%s->%s", FormatNull(p.CaughtDark), FormatNull(v))) + } + p.CaughtDark = v + p.dirty = true + } +} + +func (p *Player) SetCaughtFairy(v null.Int) { + if p.CaughtFairy != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CaughtFairy:%s->%s", FormatNull(p.CaughtFairy), FormatNull(v))) + } + p.CaughtFairy = v + p.dirty = true + } +} + +func (p *Player) SetLastSeen(v int64) { + if p.LastSeen != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LastSeen:%d->%d", p.LastSeen, v)) + } + p.LastSeen = v + p.dirty = true + } } var badgeTypeToPlayerKey = map[pogo.HoloBadgeType]string{ @@ -194,7 +1050,7 @@ func getPlayerRecord(db db.DbDetails, name string, friendshipId string, friendCo inMemoryPlayer := playerCache.Get(name) if inMemoryPlayer != nil { player := inMemoryPlayer.Value() - return &player, nil + return player, nil } player := Player{} @@ -202,7 +1058,7 @@ func getPlayerRecord(db db.DbDetails, name string, friendshipId string, friendCo ` SELECT * FROM player - WHERE player.name = ? + WHERE player.name = ? `, name, ) @@ -213,7 +1069,7 @@ func getPlayerRecord(db db.DbDetails, name string, friendshipId string, friendCo ` SELECT * FROM player - WHERE player.friendship_id = ? + WHERE player.friendship_id = ? `, friendshipId, ) @@ -223,7 +1079,7 @@ func getPlayerRecord(db db.DbDetails, name string, friendshipId string, friendCo ` SELECT * FROM player - WHERE player.friend_code = ? + WHERE player.friend_code = ? `, friendCode, ) @@ -243,111 +1099,27 @@ func getPlayerRecord(db db.DbDetails, name string, friendshipId string, friendCo return nil, err } - playerCache.Set(name, player, ttlcache.DefaultTTL) + playerCache.Set(name, &player, ttlcache.DefaultTTL) return &player, nil } -// hasChangesPlayer compares two Player structs -// Float tolerance: KmWalked = 0.001 -func hasChangesPlayer(old *Player, new *Player) bool { - return old.Name != new.Name || - old.FriendshipId != new.FriendshipId || - old.LastSeen != new.LastSeen || - old.FriendCode != new.FriendCode || - old.Team != new.Team || - old.Level != new.Level || - old.Xp != new.Xp || - old.BattlesWon != new.BattlesWon || - old.CaughtPokemon != new.CaughtPokemon || - old.GblRank != new.GblRank || - old.GblRating != new.GblRating || - old.EventBadges != new.EventBadges || - old.StopsSpun != new.StopsSpun || - old.Evolved != new.Evolved || - old.Hatched != new.Hatched || - old.Quests != new.Quests || - old.Trades != new.Trades || - old.Photobombs != new.Photobombs || - old.Purified != new.Purified || - old.GruntsDefeated != new.GruntsDefeated || - old.GymBattlesWon != new.GymBattlesWon || - old.NormalRaidsWon != new.NormalRaidsWon || - old.LegendaryRaidsWon != new.LegendaryRaidsWon || - old.TrainingsWon != new.TrainingsWon || - old.BerriesFed != new.BerriesFed || - old.HoursDefended != new.HoursDefended || - old.BestFriends != new.BestFriends || - old.BestBuddies != new.BestBuddies || - old.GiovanniDefeated != new.GiovanniDefeated || - old.MegaEvos != new.MegaEvos || - old.CollectionsDone != new.CollectionsDone || - old.UniqueStopsSpun != new.UniqueStopsSpun || - old.UniqueMegaEvos != new.UniqueMegaEvos || - old.UniqueRaidBosses != new.UniqueRaidBosses || - old.UniqueUnown != new.UniqueUnown || - old.SevenDayStreaks != new.SevenDayStreaks || - old.TradeKm != new.TradeKm || - old.RaidsWithFriends != new.RaidsWithFriends || - old.CaughtAtLure != new.CaughtAtLure || - old.WayfarerAgreements != new.WayfarerAgreements || - old.TrainersReferred != new.TrainersReferred || - old.RaidAchievements != new.RaidAchievements || - old.XlKarps != new.XlKarps || - old.XsRats != new.XsRats || - old.PikachuCaught != new.PikachuCaught || - old.LeagueGreatWon != new.LeagueGreatWon || - old.LeagueUltraWon != new.LeagueUltraWon || - old.LeagueMasterWon != new.LeagueMasterWon || - old.TinyPokemonCaught != new.TinyPokemonCaught || - old.JumboPokemonCaught != new.JumboPokemonCaught || - old.Vivillon != new.Vivillon || - old.MaxSizeFirstPlace != new.MaxSizeFirstPlace || - old.TotalRoutePlay != new.TotalRoutePlay || - old.PartiesCompleted != new.PartiesCompleted || - old.EventCheckIns != new.EventCheckIns || - old.DexGen1 != new.DexGen1 || - old.DexGen2 != new.DexGen2 || - old.DexGen3 != new.DexGen3 || - old.DexGen4 != new.DexGen4 || - old.DexGen5 != new.DexGen5 || - old.DexGen6 != new.DexGen6 || - old.DexGen7 != new.DexGen7 || - old.DexGen8 != new.DexGen8 || - old.DexGen8A != new.DexGen8A || - old.DexGen9 != new.DexGen9 || - old.CaughtNormal != new.CaughtNormal || - old.CaughtFighting != new.CaughtFighting || - old.CaughtFlying != new.CaughtFlying || - old.CaughtPoison != new.CaughtPoison || - old.CaughtGround != new.CaughtGround || - old.CaughtRock != new.CaughtRock || - old.CaughtBug != new.CaughtBug || - old.CaughtGhost != new.CaughtGhost || - old.CaughtSteel != new.CaughtSteel || - old.CaughtFire != new.CaughtFire || - old.CaughtWater != new.CaughtWater || - old.CaughtGrass != new.CaughtGrass || - old.CaughtElectric != new.CaughtElectric || - old.CaughtPsychic != new.CaughtPsychic || - old.CaughtIce != new.CaughtIce || - old.CaughtDragon != new.CaughtDragon || - old.CaughtDark != new.CaughtDark || - old.CaughtFairy != new.CaughtFairy || - !nullFloatAlmostEqual(old.KmWalked, new.KmWalked, 0.001) -} - func savePlayerRecord(db db.DbDetails, player *Player) { - oldPlayer, _ := getPlayerRecord(db, player.Name, player.FriendshipId.String, player.FriendCode.String) - - if oldPlayer != nil && !hasChangesPlayer(oldPlayer, player) { + // Skip save if not dirty and not new + if !player.IsDirty() && !player.IsNewRecord() { return } - //log.Traceln(cmp.Diff(oldPlayer, player, transformNullFloats, ignoreApproxFloats)) + player.SetLastSeen(time.Now().Unix()) - player.LastSeen = time.Now().Unix() + if dbDebugEnabled { + if player.IsNewRecord() { + dbDebugLog("INSERT", "Player", player.Name, player.changedFields) + } else { + dbDebugLog("UPDATE", "Player", player.Name, player.changedFields) + } + } - if oldPlayer == nil { + if player.IsNewRecord() { _, err := db.GeneralDb.NamedExec( ` INSERT INTO player (name, friendship_id, friend_code, last_seen, team, level, xp, battles_won, km_walked, caught_pokemon, gbl_rank, gbl_rating, @@ -384,88 +1156,88 @@ func savePlayerRecord(db db.DbDetails, player *Player) { } else { _, err := db.GeneralDb.NamedExec( `UPDATE player SET - friendship_id = :friendship_id, - last_seen = :last_seen, - team = :team, - level = :level, - xp = :xp, - battles_won = :battles_won, - km_walked = :km_walked, - caught_pokemon = :caught_pokemon, - gbl_rank = :gbl_rank, - gbl_rating = :gbl_rating, - event_badges = :event_badges, - stops_spun = :stops_spun, - evolved = :evolved, - hatched = :hatched, - quests = :quests, - trades = :trades, - photobombs = :photobombs, - purified = :purified, - grunts_defeated = :grunts_defeated, - gym_battles_won = :gym_battles_won, - normal_raids_won = :normal_raids_won, - legendary_raids_won = :legendary_raids_won, - trainings_won = :trainings_won, - berries_fed = :berries_fed, - hours_defended = :hours_defended, - best_friends = :best_friends, - best_buddies = :best_buddies, - giovanni_defeated = :giovanni_defeated, - mega_evos = :mega_evos, - collections_done = :collections_done, - unique_stops_spun = :unique_stops_spun, - unique_mega_evos = :unique_mega_evos, - unique_raid_bosses = :unique_raid_bosses, - unique_unown = :unique_unown, - seven_day_streaks = :seven_day_streaks, - trade_km = :trade_km, - raids_with_friends = :raids_with_friends, - caught_at_lure = :caught_at_lure, - wayfarer_agreements = :wayfarer_agreements, - trainers_referred = :trainers_referred, - raid_achievements = :raid_achievements, - xl_karps = :xl_karps, - xs_rats = :xs_rats, - pikachu_caught = :pikachu_caught, - league_great_won = :league_great_won, - league_ultra_won = :league_ultra_won, - league_master_won = :league_master_won, - tiny_pokemon_caught = :tiny_pokemon_caught, - jumbo_pokemon_caught = :jumbo_pokemon_caught, - vivillon = :vivillon, + friendship_id = :friendship_id, + last_seen = :last_seen, + team = :team, + level = :level, + xp = :xp, + battles_won = :battles_won, + km_walked = :km_walked, + caught_pokemon = :caught_pokemon, + gbl_rank = :gbl_rank, + gbl_rating = :gbl_rating, + event_badges = :event_badges, + stops_spun = :stops_spun, + evolved = :evolved, + hatched = :hatched, + quests = :quests, + trades = :trades, + photobombs = :photobombs, + purified = :purified, + grunts_defeated = :grunts_defeated, + gym_battles_won = :gym_battles_won, + normal_raids_won = :normal_raids_won, + legendary_raids_won = :legendary_raids_won, + trainings_won = :trainings_won, + berries_fed = :berries_fed, + hours_defended = :hours_defended, + best_friends = :best_friends, + best_buddies = :best_buddies, + giovanni_defeated = :giovanni_defeated, + mega_evos = :mega_evos, + collections_done = :collections_done, + unique_stops_spun = :unique_stops_spun, + unique_mega_evos = :unique_mega_evos, + unique_raid_bosses = :unique_raid_bosses, + unique_unown = :unique_unown, + seven_day_streaks = :seven_day_streaks, + trade_km = :trade_km, + raids_with_friends = :raids_with_friends, + caught_at_lure = :caught_at_lure, + wayfarer_agreements = :wayfarer_agreements, + trainers_referred = :trainers_referred, + raid_achievements = :raid_achievements, + xl_karps = :xl_karps, + xs_rats = :xs_rats, + pikachu_caught = :pikachu_caught, + league_great_won = :league_great_won, + league_ultra_won = :league_ultra_won, + league_master_won = :league_master_won, + tiny_pokemon_caught = :tiny_pokemon_caught, + jumbo_pokemon_caught = :jumbo_pokemon_caught, + vivillon = :vivillon, showcase_max_size_first_place = :showcase_max_size_first_place, total_route_play = :total_route_play, parties_completed = :parties_completed, - event_check_ins = :event_check_ins, - dex_gen1 = :dex_gen1, - dex_gen2 = :dex_gen2, - dex_gen3 = :dex_gen3, - dex_gen4 = :dex_gen4, - dex_gen5 = :dex_gen5, - dex_gen6 = :dex_gen6, - dex_gen7 = :dex_gen7, - dex_gen8 = :dex_gen8, - dex_gen8a = :dex_gen8a, + event_check_ins = :event_check_ins, + dex_gen1 = :dex_gen1, + dex_gen2 = :dex_gen2, + dex_gen3 = :dex_gen3, + dex_gen4 = :dex_gen4, + dex_gen5 = :dex_gen5, + dex_gen6 = :dex_gen6, + dex_gen7 = :dex_gen7, + dex_gen8 = :dex_gen8, + dex_gen8a = :dex_gen8a, dex_gen9 = :dex_gen9, - caught_normal = :caught_normal, - caught_fighting = :caught_fighting, - caught_flying = :caught_flying, - caught_poison = :caught_poison, - caught_ground = :caught_ground, - caught_rock = :caught_rock, - caught_bug = :caught_bug, - caught_ghost = :caught_ghost, - caught_steel = :caught_steel, - caught_fire = :caught_fire, - caught_water = :caught_water, - caught_grass = :caught_grass, - caught_electric = :caught_electric, - caught_psychic = :caught_psychic, - caught_ice = :caught_ice, - caught_dragon = :caught_dragon, - caught_dark = :caught_dark, - caught_fairy = :caught_fairy + caught_normal = :caught_normal, + caught_fighting = :caught_fighting, + caught_flying = :caught_flying, + caught_poison = :caught_poison, + caught_ground = :caught_ground, + caught_rock = :caught_rock, + caught_bug = :caught_bug, + caught_ghost = :caught_ghost, + caught_steel = :caught_steel, + caught_fire = :caught_fire, + caught_water = :caught_water, + caught_grass = :caught_grass, + caught_electric = :caught_electric, + caught_psychic = :caught_psychic, + caught_ice = :caught_ice, + caught_dragon = :caught_dragon, + caught_dark = :caught_dark, + caught_fairy = :caught_fairy WHERE name = :name`, player, ) @@ -476,19 +1248,23 @@ func savePlayerRecord(db db.DbDetails, player *Player) { } } - playerCache.Set(player.Name, *player, ttlcache.DefaultTTL) + player.ClearDirty() + if player.IsNewRecord() { + player.newRecord = false + playerCache.Set(player.Name, player, ttlcache.DefaultTTL) + } } func (player *Player) updateFromPublicProfile(publicProfile *pogo.PlayerPublicProfileProto) { - player.Name = publicProfile.GetName() - player.Team = null.IntFrom(int64(publicProfile.GetTeam())) - player.Level = null.IntFrom(int64(publicProfile.GetLevel())) - player.Xp = null.IntFrom(publicProfile.GetExperience()) - player.BattlesWon = null.IntFrom(int64(publicProfile.GetBattlesWon())) - player.KmWalked = null.FloatFrom(float64(publicProfile.GetKmWalked())) - player.CaughtPokemon = null.IntFrom(int64(publicProfile.GetCaughtPokemon())) - player.GblRank = null.IntFrom(int64(publicProfile.GetCombatRank())) - player.GblRating = null.IntFrom(int64(publicProfile.GetCombatRating())) + player.Name = publicProfile.GetName() // Name is primary key, don't track as dirty + player.SetTeam(null.IntFrom(int64(publicProfile.GetTeam()))) + player.SetLevel(null.IntFrom(int64(publicProfile.GetLevel()))) + player.SetXp(null.IntFrom(publicProfile.GetExperience())) + player.SetBattlesWon(null.IntFrom(int64(publicProfile.GetBattlesWon()))) + player.SetKmWalked(null.FloatFrom(float64(publicProfile.GetKmWalked()))) + player.SetCaughtPokemon(null.IntFrom(int64(publicProfile.GetCaughtPokemon()))) + player.SetGblRank(null.IntFrom(int64(publicProfile.GetCombatRank()))) + player.SetGblRating(null.IntFrom(int64(publicProfile.GetCombatRating()))) eventBadges := "" @@ -514,13 +1290,15 @@ func (player *Player) updateFromPublicProfile(publicProfile *pogo.PlayerPublicPr field := reflect.ValueOf(player).Elem().FieldByName(playerKey) if field.IsValid() && field.CanSet() { - field.Set(reflect.ValueOf(newValue)) + oldValue := field.Interface().(null.Int) + if oldValue != newValue { + field.Set(reflect.ValueOf(newValue)) + player.setFieldDirty() + } } } - if eventBadges != "" { - player.EventBadges = null.StringFrom(eventBadges) - } + player.SetEventBadges(null.StringFrom(eventBadges)) } func UpdatePlayerRecordWithPlayerSummary(db db.DbDetails, playerSummary *pogo.InternalPlayerSummaryProto, publicProfile *pogo.PlayerPublicProfileProto, friendCode string, friendshipId string) error { @@ -531,15 +1309,16 @@ func UpdatePlayerRecordWithPlayerSummary(db db.DbDetails, playerSummary *pogo.In if player == nil { player = &Player{ - Name: playerSummary.GetCodename(), + Name: playerSummary.GetCodename(), + newRecord: true, } } if player.FriendshipId.IsZero() && friendshipId != "" { - player.FriendshipId = null.StringFrom(friendshipId) + player.SetFriendshipId(null.StringFrom(friendshipId)) } if player.FriendCode.IsZero() && friendCode != "" { - player.FriendCode = null.StringFrom(friendCode) + player.SetFriendCode(null.StringFrom(friendCode)) } player.updateFromPublicProfile(publicProfile) diff --git a/decoder/pokemon.go b/decoder/pokemon.go index 9244cc2b..56cf62b1 100644 --- a/decoder/pokemon.go +++ b/decoder/pokemon.go @@ -1,29 +1,12 @@ package decoder import ( - "context" - "database/sql" - "encoding/json" - "errors" "fmt" - "strconv" - "strings" "sync" - "time" - "golbat/config" - "golbat/db" - "golbat/geo" "golbat/grpc" - "golbat/pogo" - "golbat/webhooks" - "github.com/UnownHash/gohbem" - "github.com/golang/geo/s2" - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - "google.golang.org/protobuf/proto" - "gopkg.in/guregu/null.v4" + "github.com/guregu/null/v6" ) // Pokemon struct. @@ -35,47 +18,65 @@ import ( // // FirstSeenTimestamp: This field is used in IsNewRecord. It should only be set in savePokemonRecord. type Pokemon struct { - Id uint64 `db:"id" json:"id,string"` - PokestopId null.String `db:"pokestop_id" json:"pokestop_id"` - SpawnId null.Int `db:"spawn_id" json:"spawn_id"` - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - Weight null.Float `db:"weight" json:"weight"` - Size null.Int `db:"size" json:"size"` - Height null.Float `db:"height" json:"height"` - ExpireTimestamp null.Int `db:"expire_timestamp" json:"expire_timestamp"` - Updated null.Int `db:"updated" json:"updated"` - PokemonId int16 `db:"pokemon_id" json:"pokemon_id"` - Move1 null.Int `db:"move_1" json:"move_1"` - Move2 null.Int `db:"move_2" json:"move_2"` - Gender null.Int `db:"gender" json:"gender"` - Cp null.Int `db:"cp" json:"cp"` - AtkIv null.Int `db:"atk_iv" json:"atk_iv"` - DefIv null.Int `db:"def_iv" json:"def_iv"` - StaIv null.Int `db:"sta_iv" json:"sta_iv"` - GolbatInternal []byte `db:"golbat_internal" json:"golbat_internal"` - Iv null.Float `db:"iv" json:"iv"` - Form null.Int `db:"form" json:"form"` - Level null.Int `db:"level" json:"level"` - IsStrong null.Bool `db:"strong" json:"strong"` - Weather null.Int `db:"weather" json:"weather"` - Costume null.Int `db:"costume" json:"costume"` - FirstSeenTimestamp int64 `db:"first_seen_timestamp" json:"first_seen_timestamp"` - Changed int64 `db:"changed" json:"changed"` - CellId null.Int `db:"cell_id" json:"cell_id"` - ExpireTimestampVerified bool `db:"expire_timestamp_verified" json:"expire_timestamp_verified"` - DisplayPokemonId null.Int `db:"display_pokemon_id" json:"display_pokemon_id"` - IsDitto bool `db:"is_ditto" json:"is_ditto"` - SeenType null.String `db:"seen_type" json:"seen_type"` - Shiny null.Bool `db:"shiny" json:"shiny"` - Username null.String `db:"username" json:"username"` - Capture1 null.Float `db:"capture_1" json:"capture_1"` - Capture2 null.Float `db:"capture_2" json:"capture_2"` - Capture3 null.Float `db:"capture_3" json:"capture_3"` - Pvp null.String `db:"pvp" json:"pvp"` - IsEvent int8 `db:"is_event" json:"is_event"` + mu sync.Mutex `db:"-"` // Object-level mutex + + Id uint64 `db:"id"` + PokestopId null.String `db:"pokestop_id"` + SpawnId null.Int `db:"spawn_id"` + Lat float64 `db:"lat"` + Lon float64 `db:"lon"` + Weight null.Float `db:"weight"` + Size null.Int `db:"size"` + Height null.Float `db:"height"` + ExpireTimestamp null.Int `db:"expire_timestamp"` + Updated null.Int `db:"updated"` + PokemonId int16 `db:"pokemon_id"` + Move1 null.Int `db:"move_1"` + Move2 null.Int `db:"move_2"` + Gender null.Int `db:"gender"` + Cp null.Int `db:"cp"` + AtkIv null.Int `db:"atk_iv"` + DefIv null.Int `db:"def_iv"` + StaIv null.Int `db:"sta_iv"` + GolbatInternal []byte `db:"golbat_internal"` + Iv null.Float `db:"iv"` + Form null.Int `db:"form"` + Level null.Int `db:"level"` + IsStrong null.Bool `db:"strong"` + Weather null.Int `db:"weather"` + Costume null.Int `db:"costume"` + FirstSeenTimestamp int64 `db:"first_seen_timestamp"` + Changed int64 `db:"changed"` + CellId null.Int `db:"cell_id"` + ExpireTimestampVerified bool `db:"expire_timestamp_verified"` + DisplayPokemonId null.Int `db:"display_pokemon_id"` + IsDitto bool `db:"is_ditto"` + SeenType null.String `db:"seen_type"` + Shiny null.Bool `db:"shiny"` + Username null.String `db:"username"` + Capture1 null.Float `db:"capture_1"` + Capture2 null.Float `db:"capture_2"` + Capture3 null.Float `db:"capture_3"` + Pvp null.String `db:"pvp"` + IsEvent int8 `db:"is_event"` internal grpc.PokemonInternal + + dirty bool `db:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-"` + changedFields []string `db:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues PokemonOldValues `db:"-"` // Old values for webhook comparison and stats +} + +// PokemonOldValues holds old field values for webhook comparison, stats, and R-tree updates +type PokemonOldValues struct { + PokemonId int16 + Weather null.Int + Cp null.Int + SeenType null.String + Lat float64 + Lon float64 } // @@ -131,1342 +132,354 @@ type Pokemon struct { //KEY `ix_iv` (`iv`) //) -func getPokemonRecord(ctx context.Context, db db.DbDetails, encounterId uint64) (*Pokemon, error) { - if db.UsePokemonCache { - inMemoryPokemon := getPokemonFromCache(encounterId) - if inMemoryPokemon != nil { - pokemon := inMemoryPokemon.Value() - return &pokemon, nil - } - } - if config.Config.PokemonMemoryOnly { - return nil, nil - } - pokemon := Pokemon{} - - err := db.PokemonDb.GetContext(ctx, &pokemon, - "SELECT id, pokemon_id, lat, lon, spawn_id, expire_timestamp, atk_iv, def_iv, sta_iv, golbat_internal, iv, "+ - "move_1, move_2, gender, form, cp, level, strong, weather, costume, weight, height, size, "+ - "display_pokemon_id, is_ditto, pokestop_id, updated, first_seen_timestamp, changed, cell_id, "+ - "expire_timestamp_verified, shiny, username, pvp, is_event, seen_type "+ - "FROM pokemon WHERE id = ?", strconv.FormatUint(encounterId, 10)) - - statsCollector.IncDbQuery("select pokemon", err) - if err == sql.ErrNoRows { - return nil, nil - } - - if err != nil { - return nil, err - } +// IsDirty returns true if any field has been modified +func (pokemon *Pokemon) IsDirty() bool { + return pokemon.dirty +} - if db.UsePokemonCache { - setPokemonCache(encounterId, pokemon, ttlcache.DefaultTTL) - } - pokemonRtreeUpdatePokemonOnGet(&pokemon) - return &pokemon, nil +// ClearDirty resets the dirty flag (call after saving to DB) +func (pokemon *Pokemon) ClearDirty() { + pokemon.dirty = false } -func getOrCreatePokemonRecord(ctx context.Context, db db.DbDetails, encounterId uint64) (*Pokemon, error) { - pokemon, err := getPokemonRecord(ctx, db, encounterId) - if pokemon != nil || err != nil { - return pokemon, err - } - pokemon = &Pokemon{Id: encounterId} - if db.UsePokemonCache { - setPokemonCache(encounterId, *pokemon, ttlcache.DefaultTTL) +// snapshotOldValues saves current values for webhook comparison, stats, and R-tree updates +// Call this after loading from cache/DB but before modifications +func (pokemon *Pokemon) snapshotOldValues() { + pokemon.oldValues = PokemonOldValues{ + PokemonId: pokemon.PokemonId, + Weather: pokemon.Weather, + Cp: pokemon.Cp, + SeenType: pokemon.SeenType, + Lat: pokemon.Lat, + Lon: pokemon.Lon, } - return pokemon, nil } -// hasChangesPokemon compares two Pokemon structs -// Ignored: Username, Iv, Pvp -// Float tolerance: Lat, Lon -// Null Float tolerance: Weight, Height, Capture1, Capture2, Capture3 -func hasChangesPokemon(old *Pokemon, new *Pokemon) bool { - return old.Id != new.Id || - old.PokestopId != new.PokestopId || - old.SpawnId != new.SpawnId || - old.Size != new.Size || - old.ExpireTimestamp != new.ExpireTimestamp || - old.Updated != new.Updated || - old.PokemonId != new.PokemonId || - old.Move1 != new.Move1 || - old.Move2 != new.Move2 || - old.Gender != new.Gender || - old.Cp != new.Cp || - old.AtkIv != new.AtkIv || - old.DefIv != new.DefIv || - old.StaIv != new.StaIv || - old.Form != new.Form || - old.Level != new.Level || - old.IsStrong != new.IsStrong || - old.Weather != new.Weather || - old.Costume != new.Costume || - old.FirstSeenTimestamp != new.FirstSeenTimestamp || - old.Changed != new.Changed || - old.CellId != new.CellId || - old.ExpireTimestampVerified != new.ExpireTimestampVerified || - old.DisplayPokemonId != new.DisplayPokemonId || - old.IsDitto != new.IsDitto || - old.SeenType != new.SeenType || - old.Shiny != new.Shiny || - old.IsEvent != new.IsEvent || - !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) || - !nullFloatAlmostEqual(old.Weight, new.Weight, floatTolerance) || - !nullFloatAlmostEqual(old.Height, new.Height, floatTolerance) || - !nullFloatAlmostEqual(old.Capture1, new.Capture1, floatTolerance) || - !nullFloatAlmostEqual(old.Capture2, new.Capture2, floatTolerance) || - !nullFloatAlmostEqual(old.Capture3, new.Capture3, floatTolerance) +// Lock acquires the Pokemon's mutex +func (pokemon *Pokemon) Lock() { + pokemon.mu.Lock() } -func savePokemonRecordAsAtTime(ctx context.Context, db db.DbDetails, pokemon *Pokemon, isEncounter, writeDB, webhook bool, now int64) { - oldPokemon, _ := getPokemonRecord(ctx, db, pokemon.Id) - - if oldPokemon != nil && !hasChangesPokemon(oldPokemon, pokemon) { - return - } - - // Blank, non-persisted record are now inserted into the cache to save on DB calls - if oldPokemon != nil && oldPokemon.isNewRecord() { - oldPokemon = nil - } - - // uncomment to debug excessive writes - //if oldPokemon != nil && oldPokemon.AtkIv == pokemon.AtkIv && oldPokemon.DefIv == pokemon.DefIv && oldPokemon.StaIv == pokemon.StaIv && oldPokemon.Level == pokemon.Level && oldPokemon.ExpireTimestampVerified == pokemon.ExpireTimestampVerified && oldPokemon.PokemonId == pokemon.PokemonId && oldPokemon.ExpireTimestamp == pokemon.ExpireTimestamp && oldPokemon.PokestopId == pokemon.PokestopId && math.Abs(pokemon.Lat-oldPokemon.Lat) < .000001 && math.Abs(pokemon.Lon-oldPokemon.Lon) < .000001 { - // log.Errorf("Why are we updating this? %s", cmp.Diff(oldPokemon, pokemon, cmp.Options{ - // ignoreNearFloats, ignoreNearNullFloats, - // cmpopts.IgnoreFields(Pokemon{}, "Username", "Iv", "Pvp"), - // })) - //} - - if pokemon.FirstSeenTimestamp == 0 { - pokemon.FirstSeenTimestamp = now - } - - pokemon.Updated = null.IntFrom(now) - if oldPokemon == nil || oldPokemon.PokemonId != pokemon.PokemonId || oldPokemon.Cp != pokemon.Cp { - pokemon.Changed = now - } - - changePvpField := false - var pvpResults map[string][]gohbem.PokemonEntry - if ohbem != nil { - // Calculating PVP data - if pokemon.AtkIv.Valid && (oldPokemon == nil || oldPokemon.PokemonId != pokemon.PokemonId || - oldPokemon.Level != pokemon.Level || oldPokemon.Form != pokemon.Form || - oldPokemon.Costume != pokemon.Costume || oldPokemon.Gender != pokemon.Gender || - oldPokemon.Weather != pokemon.Weather) { - pvp, err := ohbem.QueryPvPRank(int(pokemon.PokemonId), - int(pokemon.Form.ValueOrZero()), - int(pokemon.Costume.ValueOrZero()), - int(pokemon.Gender.ValueOrZero()), - int(pokemon.AtkIv.ValueOrZero()), - int(pokemon.DefIv.ValueOrZero()), - int(pokemon.StaIv.ValueOrZero()), - float64(pokemon.Level.ValueOrZero())) - - if err == nil { - pvpBytes, _ := json.Marshal(pvp) - pokemon.Pvp = null.StringFrom(string(pvpBytes)) - changePvpField = true - pvpResults = pvp - } - } - if !pokemon.AtkIv.Valid && (oldPokemon == nil || oldPokemon.AtkIv.Valid) { - pokemon.Pvp = null.NewString("", false) - changePvpField = true - } - } - - var oldSeenType string - if oldPokemon == nil { - oldSeenType = "n/a" - } else { - oldSeenType = oldPokemon.SeenType.ValueOrZero() - } - log.Debugf("Updating pokemon [%d] from %s->%s", pokemon.Id, oldSeenType, pokemon.SeenType.ValueOrZero()) - //log.Println(cmp.Diff(oldPokemon, pokemon)) - - if writeDB && !config.Config.PokemonMemoryOnly { - if isEncounter && config.Config.PokemonInternalToDb { - unboosted, boosted, strong := pokemon.locateAllScans() - if unboosted != nil && boosted != nil { - unboosted.RemoveDittoAuxInfo() - boosted.RemoveDittoAuxInfo() - } - if strong != nil { - strong.RemoveDittoAuxInfo() - } - marshaled, err := proto.Marshal(&pokemon.internal) - if err == nil { - pokemon.GolbatInternal = marshaled - } else { - log.Errorf("[POKEMON] Failed to marshal internal data for %d, data may be lost: %s", pokemon.Id, err) - } - } - if oldPokemon == nil { - pvpField, pvpValue := "", "" - if changePvpField { - pvpField, pvpValue = "pvp, ", ":pvp, " - } - res, err := db.PokemonDb.NamedExecContext(ctx, fmt.Sprintf("INSERT INTO pokemon (id, pokemon_id, lat, lon,"+ - "spawn_id, expire_timestamp, atk_iv, def_iv, sta_iv, golbat_internal, iv, move_1, move_2,"+ - "gender, form, cp, level, strong, weather, costume, weight, height, size,"+ - "display_pokemon_id, is_ditto, pokestop_id, updated, first_seen_timestamp, changed, cell_id,"+ - "expire_timestamp_verified, shiny, username, %s is_event, seen_type) "+ - "VALUES (\"%d\", :pokemon_id, :lat, :lon, :spawn_id, :expire_timestamp, :atk_iv, :def_iv, :sta_iv,"+ - ":golbat_internal, :iv, :move_1, :move_2, :gender, :form, :cp, :level, :strong, :weather, :costume,"+ - ":weight, :height, :size, :display_pokemon_id, :is_ditto, :pokestop_id, :updated,"+ - ":first_seen_timestamp, :changed, :cell_id, :expire_timestamp_verified, :shiny, :username, %s :is_event,"+ - ":seen_type)", pvpField, pokemon.Id, pvpValue), pokemon) - - statsCollector.IncDbQuery("insert pokemon", err) - if err != nil { - log.Errorf("insert pokemon: [%d] %s", pokemon.Id, err) - log.Errorf("Full structure: %+v", pokemon) - deletePokemonFromCache(pokemon.Id) // Force reload of pokemon from database - return - } +// Unlock releases the Pokemon's mutex +func (pokemon *Pokemon) Unlock() { + pokemon.mu.Unlock() +} - _, _ = res, err - } else { - pvpUpdate := "" - if changePvpField { - pvpUpdate = "pvp = :pvp, " - } - res, err := db.PokemonDb.NamedExecContext(ctx, fmt.Sprintf("UPDATE pokemon SET "+ - "pokestop_id = :pokestop_id, "+ - "spawn_id = :spawn_id, "+ - "lat = :lat, "+ - "lon = :lon, "+ - "weight = :weight, "+ - "height = :height, "+ - "size = :size, "+ - "expire_timestamp = :expire_timestamp, "+ - "updated = :updated, "+ - "pokemon_id = :pokemon_id, "+ - "move_1 = :move_1, "+ - "move_2 = :move_2, "+ - "gender = :gender, "+ - "cp = :cp, "+ - "atk_iv = :atk_iv, "+ - "def_iv = :def_iv, "+ - "sta_iv = :sta_iv, "+ - "golbat_internal = :golbat_internal,"+ - "iv = :iv,"+ - "form = :form, "+ - "level = :level, "+ - "strong = :strong, "+ - "weather = :weather, "+ - "costume = :costume, "+ - "first_seen_timestamp = :first_seen_timestamp, "+ - "changed = :changed, "+ - "cell_id = :cell_id, "+ - "expire_timestamp_verified = :expire_timestamp_verified, "+ - "display_pokemon_id = :display_pokemon_id, "+ - "is_ditto = :is_ditto, "+ - "seen_type = :seen_type, "+ - "shiny = :shiny, "+ - "username = :username, "+ - "%s"+ - "is_event = :is_event "+ - "WHERE id = \"%d\"", pvpUpdate, pokemon.Id), pokemon, - ) - statsCollector.IncDbQuery("update pokemon", err) - if err != nil { - log.Errorf("Update pokemon [%d] %s", pokemon.Id, err) - log.Errorf("Full structure: %+v", pokemon) - deletePokemonFromCache(pokemon.Id) // Force reload of pokemon from database +// --- Set methods with dirty tracking --- - return - } - rows, rowsErr := res.RowsAffected() - log.Debugf("Updating pokemon [%d] after update res = %d %v", pokemon.Id, rows, rowsErr) +func (pokemon *Pokemon) SetPokestopId(v null.String) { + if pokemon.PokestopId != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("PokestopId:%s->%s", FormatNull(pokemon.PokestopId), FormatNull(v))) } + pokemon.PokestopId = v + pokemon.dirty = true } +} - // Update pokemon rtree - if oldPokemon == nil { - addPokemonToTree(pokemon) - } else { - if pokemon.Lat != oldPokemon.Lat || pokemon.Lon != oldPokemon.Lon { - removePokemonFromTree(oldPokemon) - addPokemonToTree(pokemon) +func (pokemon *Pokemon) SetSpawnId(v null.Int) { + if pokemon.SpawnId != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("SpawnId:%s->%s", FormatNull(pokemon.SpawnId), FormatNull(v))) } - } - - updatePokemonLookup(pokemon, changePvpField, pvpResults) - - areas := MatchStatsGeofence(pokemon.Lat, pokemon.Lon) - if webhook { - createPokemonWebhooks(ctx, db, oldPokemon, pokemon, areas) - } - updatePokemonStats(oldPokemon, pokemon, areas, now) - - pokemon.Pvp = null.NewString("", false) // Reset PVP field to avoid keeping it in memory cache - - if db.UsePokemonCache { - setPokemonCache(pokemon.Id, *pokemon, pokemon.remainingDuration(now)) + pokemon.SpawnId = v + pokemon.dirty = true } } -func createPokemonWebhooks(ctx context.Context, db db.DbDetails, old *Pokemon, new *Pokemon, areas []geo.AreaName) { - //nullString := func (v null.Int) interface{} { - // if !v.Valid { - // return "null" - // } - // return v.ValueOrZero() - //} - - if old == nil || - old.PokemonId != new.PokemonId || - old.Weather != new.Weather || - old.Cp != new.Cp { - pokemonHook := map[string]interface{}{ - "spawnpoint_id": func() string { - if !new.SpawnId.Valid { - return "None" - } - return strconv.FormatInt(new.SpawnId.ValueOrZero(), 16) - }(), - "pokestop_id": func() string { - if !new.PokestopId.Valid { - return "None" - } else { - return new.PokestopId.ValueOrZero() - } - }(), - "pokestop_name": func() *string { - if !new.PokestopId.Valid { - return nil - } else { - pokestop, _ := GetPokestopRecord(ctx, db, new.PokestopId.String) - name := "Unknown" - if pokestop != nil { - name = pokestop.Name.ValueOrZero() - } - return &name - } - }(), - "encounter_id": strconv.FormatUint(new.Id, 10), - "pokemon_id": new.PokemonId, - "latitude": new.Lat, - "longitude": new.Lon, - "disappear_time": new.ExpireTimestamp.ValueOrZero(), - "disappear_time_verified": new.ExpireTimestampVerified, - "first_seen": new.FirstSeenTimestamp, - "last_modified_time": new.Updated, - "gender": new.Gender, - "cp": new.Cp, - "form": new.Form, - "costume": new.Costume, - "individual_attack": new.AtkIv, - "individual_defense": new.DefIv, - "individual_stamina": new.StaIv, - "pokemon_level": new.Level, - "move_1": new.Move1, - "move_2": new.Move2, - "weight": new.Weight, - "size": new.Size, - "height": new.Height, - "weather": new.Weather, - "capture_1": new.Capture1.ValueOrZero(), - "capture_2": new.Capture2.ValueOrZero(), - "capture_3": new.Capture3.ValueOrZero(), - "shiny": new.Shiny, - "username": new.Username, - "display_pokemon_id": new.DisplayPokemonId, - "is_event": new.IsEvent, - "seen_type": new.SeenType, - "pvp": func() interface{} { - if !new.Pvp.Valid { - return nil - } else { - return json.RawMessage(new.Pvp.ValueOrZero()) - } - }(), - } - - if new.AtkIv.Valid && new.DefIv.Valid && new.StaIv.Valid { - webhooksSender.AddMessage(webhooks.PokemonIV, pokemonHook, areas) - } else { - webhooksSender.AddMessage(webhooks.PokemonNoIV, pokemonHook, areas) +func (pokemon *Pokemon) SetLat(v float64) { + if !floatAlmostEqual(pokemon.Lat, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Lat:%f->%f", pokemon.Lat, v)) } + pokemon.Lat = v + pokemon.dirty = true } } -func (pokemon *Pokemon) populateInternal() { - if len(pokemon.GolbatInternal) == 0 || len(pokemon.internal.ScanHistory) != 0 { - return - } - err := proto.Unmarshal(pokemon.GolbatInternal, &pokemon.internal) - if err != nil { - log.Warnf("Failed to parse internal data for %d: %s", pokemon.Id, err) - pokemon.internal.Reset() +func (pokemon *Pokemon) SetLon(v float64) { + if !floatAlmostEqual(pokemon.Lon, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Lon:%f->%f", pokemon.Lon, v)) + } + pokemon.Lon = v + pokemon.dirty = true } } -func (pokemon *Pokemon) locateScan(isStrong bool, isBoosted bool) (*grpc.PokemonScan, bool) { - pokemon.populateInternal() - var bestMatching *grpc.PokemonScan - for _, entry := range pokemon.internal.ScanHistory { - if entry.Strong != isStrong { - continue - } - if entry.Weather != int32(pogo.GameplayWeatherProto_NONE) == isBoosted { - return entry, true - } else { - bestMatching = entry +func (pokemon *Pokemon) SetPokemonId(v int16) { + if pokemon.PokemonId != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("PokemonId:%d->%d", pokemon.PokemonId, v)) } + pokemon.PokemonId = v + pokemon.dirty = true } - return bestMatching, false } -func (pokemon *Pokemon) locateAllScans() (unboosted, boosted, strong *grpc.PokemonScan) { - pokemon.populateInternal() - for _, entry := range pokemon.internal.ScanHistory { - if entry.Strong { - strong = entry - } else if entry.Weather != int32(pogo.GameplayWeatherProto_NONE) { - boosted = entry - } else { - unboosted = entry +func (pokemon *Pokemon) SetForm(v null.Int) { + if pokemon.Form != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Form:%s->%s", FormatNull(pokemon.Form), FormatNull(v))) } + pokemon.Form = v + pokemon.dirty = true } - return } -func (pokemon *Pokemon) isNewRecord() bool { - return pokemon.FirstSeenTimestamp == 0 -} - -func (pokemon *Pokemon) remainingDuration(now int64) time.Duration { - remaining := ttlcache.DefaultTTL - if pokemon.ExpireTimestampVerified { - timeLeft := 60 + pokemon.ExpireTimestamp.ValueOrZero() - now - if timeLeft > 1 { - remaining = time.Duration(timeLeft) * time.Second +func (pokemon *Pokemon) SetCostume(v null.Int) { + if pokemon.Costume != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Costume:%s->%s", FormatNull(pokemon.Costume), FormatNull(v))) } + pokemon.Costume = v + pokemon.dirty = true } - return remaining } -func (pokemon *Pokemon) addWildPokemon(ctx context.Context, db db.DbDetails, wildPokemon *pogo.WildPokemonProto, timestampMs int64, trustworthyTimestamp bool) { - if wildPokemon.EncounterId != pokemon.Id { - panic("Unmatched EncounterId") - } - pokemon.Lat = wildPokemon.Latitude - pokemon.Lon = wildPokemon.Longitude - - spawnId, err := strconv.ParseInt(wildPokemon.SpawnPointId, 16, 64) - if err != nil { - panic(err) +func (pokemon *Pokemon) SetGender(v null.Int) { + if pokemon.Gender != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Gender:%s->%s", FormatNull(pokemon.Gender), FormatNull(v))) + } + pokemon.Gender = v + pokemon.dirty = true } - pokemon.SpawnId = null.IntFrom(spawnId) - - pokemon.setExpireTimestampFromSpawnpoint(ctx, db, timestampMs, trustworthyTimestamp) - pokemon.setPokemonDisplay(int16(wildPokemon.Pokemon.PokemonId), wildPokemon.Pokemon.PokemonDisplay) -} - -// wildSignificantUpdate returns true if the wild pokemon is significantly different from the current pokemon and -// should be written. -func (pokemon *Pokemon) wildSignificantUpdate(wildPokemon *pogo.WildPokemonProto, time int64) bool { - pokemonDisplay := wildPokemon.Pokemon.PokemonDisplay - // We would accept a wild update if the pokemon has changed; or to extend an unknown spawn time that is expired - - return pokemon.SeenType.ValueOrZero() == SeenType_Cell || - pokemon.SeenType.ValueOrZero() == SeenType_NearbyStop || - pokemon.PokemonId != int16(wildPokemon.Pokemon.PokemonId) || - pokemon.Form.ValueOrZero() != int64(pokemonDisplay.Form) || - pokemon.Weather.ValueOrZero() != int64(pokemonDisplay.WeatherBoostedCondition) || - pokemon.Costume.ValueOrZero() != int64(pokemonDisplay.Costume) || - pokemon.Gender.ValueOrZero() != int64(pokemonDisplay.Gender) || - (!pokemon.ExpireTimestampVerified && pokemon.ExpireTimestamp.ValueOrZero() < time) } -func (pokemon *Pokemon) updateFromWild(ctx context.Context, db db.DbDetails, wildPokemon *pogo.WildPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { - pokemon.IsEvent = 0 - switch pokemon.SeenType.ValueOrZero() { - case "", SeenType_Cell, SeenType_NearbyStop: - pokemon.SeenType = null.StringFrom(SeenType_Wild) +func (pokemon *Pokemon) SetWeather(v null.Int) { + if pokemon.Weather != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Weather:%s->%s", FormatNull(pokemon.Weather), FormatNull(v))) + } + pokemon.Weather = v + pokemon.dirty = true } - pokemon.addWildPokemon(ctx, db, wildPokemon, timestampMs, true) - pokemon.recomputeCpIfNeeded(ctx, db, weather) - pokemon.Username = null.StringFrom(username) - pokemon.CellId = null.IntFrom(cellId) } -func (pokemon *Pokemon) updateFromMap(ctx context.Context, db db.DbDetails, mapPokemon *pogo.MapPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { - - if !pokemon.isNewRecord() { - // Do not ever overwrite lure details based on seeing it again in the GMO - return - } - - pokemon.IsEvent = 0 - - pokemon.Id = mapPokemon.EncounterId - - spawnpointId := mapPokemon.SpawnpointId - - pokestop, _ := GetPokestopRecord(ctx, db, spawnpointId) - if pokestop == nil { - // Unrecognised pokestop - return - } - pokemon.PokestopId = null.StringFrom(pokestop.Id) - pokemon.Lat = pokestop.Lat - pokemon.Lon = pokestop.Lon - pokemon.SeenType = null.StringFrom(SeenType_LureWild) - - if mapPokemon.PokemonDisplay != nil { - pokemon.setPokemonDisplay(int16(mapPokemon.PokedexTypeId), mapPokemon.PokemonDisplay) - pokemon.recomputeCpIfNeeded(ctx, db, weather) - // The mapPokemon and nearbyPokemon GMOs don't contain actual shininess. - // shiny = mapPokemon.pokemonDisplay.shiny - } else { - log.Warnf("[POKEMON] MapPokemonProto missing PokemonDisplay for %d", pokemon.Id) - } - if !pokemon.Username.Valid { - pokemon.Username = null.StringFrom(username) - } - - if mapPokemon.ExpirationTimeMs > 0 && !pokemon.ExpireTimestampVerified { - pokemon.ExpireTimestamp = null.IntFrom(mapPokemon.ExpirationTimeMs / 1000) - pokemon.ExpireTimestampVerified = true - // if we have cached an encounter for this pokemon, update the TTL. - encounterCache.UpdateTTL(pokemon.Id, pokemon.remainingDuration(timestampMs/1000)) - } else { - pokemon.ExpireTimestampVerified = false +func (pokemon *Pokemon) SetIsStrong(v null.Bool) { + if pokemon.IsStrong != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("IsStrong:%s->%s", FormatNull(pokemon.IsStrong), FormatNull(v))) + } + pokemon.IsStrong = v + pokemon.dirty = true } - - pokemon.CellId = null.IntFrom(cellId) } -func (pokemon *Pokemon) calculateIv(a int64, d int64, s int64) { - pokemon.AtkIv = null.IntFrom(a) - pokemon.DefIv = null.IntFrom(d) - pokemon.StaIv = null.IntFrom(s) - pokemon.Iv = null.FloatFrom(float64(a+d+s) / .45) -} - -func (pokemon *Pokemon) updateFromNearby(ctx context.Context, db db.DbDetails, nearbyPokemon *pogo.NearbyPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { - pokemon.IsEvent = 0 - pokestopId := nearbyPokemon.FortId - pokemon.setPokemonDisplay(int16(nearbyPokemon.PokedexNumber), nearbyPokemon.PokemonDisplay) - pokemon.recomputeCpIfNeeded(ctx, db, weather) - pokemon.Username = null.StringFrom(username) - - var lat, lon float64 - overrideLatLon := pokemon.isNewRecord() - useCellLatLon := true - if pokestopId != "" { - switch pokemon.SeenType.ValueOrZero() { - case "", SeenType_Cell: - overrideLatLon = true // a better estimate is available - case SeenType_NearbyStop: - default: - return - } - pokestop, _ := GetPokestopRecord(ctx, db, pokestopId) - if pokestop == nil { - // Unrecognised pokestop, rollback changes - overrideLatLon = pokemon.isNewRecord() - } else { - pokemon.SeenType = null.StringFrom(SeenType_NearbyStop) - pokemon.PokestopId = null.StringFrom(pokestopId) - lat, lon = pokestop.Lat, pokestop.Lon - useCellLatLon = false - } - } - if useCellLatLon { - // Cell Pokemon - if !overrideLatLon && pokemon.SeenType.ValueOrZero() != SeenType_Cell { - // do not downgrade to nearby cell - return +func (pokemon *Pokemon) SetExpireTimestamp(v null.Int) { + if pokemon.ExpireTimestamp != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("ExpireTimestamp:%s->%s", FormatNull(pokemon.ExpireTimestamp), FormatNull(v))) } - - s2cell := s2.CellFromCellID(s2.CellID(cellId)) - lat = s2cell.CapBound().RectBound().Center().Lat.Degrees() - lon = s2cell.CapBound().RectBound().Center().Lng.Degrees() - - pokemon.SeenType = null.StringFrom(SeenType_Cell) + pokemon.ExpireTimestamp = v + pokemon.dirty = true } - if overrideLatLon { - pokemon.Lat, pokemon.Lon = lat, lon - } else { - midpoint := s2.LatLngFromPoint(s2.Point{s2.PointFromLatLng(s2.LatLngFromDegrees(pokemon.Lat, pokemon.Lon)). - Add(s2.PointFromLatLng(s2.LatLngFromDegrees(lat, lon)).Vector)}) - pokemon.Lat = midpoint.Lat.Degrees() - pokemon.Lon = midpoint.Lng.Degrees() - } - pokemon.CellId = null.IntFrom(cellId) - pokemon.setUnknownTimestamp(timestampMs / 1000) } -const SeenType_Cell string = "nearby_cell" // Pokemon was seen in a cell (without accurate location) -const SeenType_NearbyStop string = "nearby_stop" // Pokemon was seen at a nearby Pokestop, location set to lon, lat of pokestop -const SeenType_Wild string = "wild" // Pokemon was seen in the wild, accurate location but with no IV details -const SeenType_Encounter string = "encounter" // Pokemon has been encountered giving exact details of current IV -const SeenType_LureWild string = "lure_wild" // Pokemon was seen at a lure -const SeenType_LureEncounter string = "lure_encounter" // Pokemon has been encountered at a lure -const SeenType_TappableEncounter string = "tappable_encounter" // Pokemon has been encountered from tappable -const SeenType_TappableLureEncounter string = "tappable_lure_encounter" // Pokemon has been encountered from a lured tappable - -// setExpireTimestampFromSpawnpoint sets the current Pokemon object ExpireTimeStamp, and ExpireTimeStampVerified from the Spawnpoint -// information held. -// db - the database connection to be used -// timestampMs - the timestamp to be used for calculations -// trustworthyTimestamp - whether this timestamp is fully trustworthy (ie comes from GMO server time) -func (pokemon *Pokemon) setExpireTimestampFromSpawnpoint(ctx context.Context, db db.DbDetails, timestampMs int64, trustworthyTimestamp bool) { - if !trustworthyTimestamp && pokemon.ExpireTimestampVerified { - // If our time is not trustworthy, and we have already set a time from some other source (eg a GMO) - // don't modify it - - return - } - - spawnId := pokemon.SpawnId.ValueOrZero() - if spawnId == 0 { - return - } - - pokemon.ExpireTimestampVerified = false - spawnPoint, _ := getSpawnpointRecord(ctx, db, spawnId) - if spawnPoint != nil && spawnPoint.DespawnSec.Valid { - despawnSecond := int(spawnPoint.DespawnSec.ValueOrZero()) - - date := time.Unix(timestampMs/1000, 0) - secondOfHour := date.Second() + date.Minute()*60 - - despawnOffset := despawnSecond - secondOfHour - if despawnOffset < 0 { - despawnOffset += 3600 +func (pokemon *Pokemon) SetExpireTimestampVerified(v bool) { + if pokemon.ExpireTimestampVerified != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("ExpireTimestampVerified:%t->%t", pokemon.ExpireTimestampVerified, v)) } - pokemon.ExpireTimestamp = null.IntFrom(int64(timestampMs)/1000 + int64(despawnOffset)) - pokemon.ExpireTimestampVerified = true - } else { - pokemon.setUnknownTimestamp(timestampMs / 1000) + pokemon.ExpireTimestampVerified = v + pokemon.dirty = true } } -func (pokemon *Pokemon) setUnknownTimestamp(now int64) { - if !pokemon.ExpireTimestamp.Valid { - pokemon.ExpireTimestamp = null.IntFrom(now + 20*60) // should be configurable, add on 20min - } else { - if pokemon.ExpireTimestamp.Int64 < now { - pokemon.ExpireTimestamp = null.IntFrom(now + 10*60) // should be configurable, add on 10min +func (pokemon *Pokemon) SetSeenType(v null.String) { + if pokemon.SeenType != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("SeenType:%s->%s", FormatNull(pokemon.SeenType), FormatNull(v))) } + pokemon.SeenType = v + pokemon.dirty = true } } -func checkScans(old *grpc.PokemonScan, new *grpc.PokemonScan) error { - if old == nil || old.CompressedIv() == new.CompressedIv() { - return nil +func (pokemon *Pokemon) SetUsername(v null.String) { + if pokemon.Username != v { + pokemon.Username = v + //pokemon.dirty = true } - return errors.New(fmt.Sprintf("Unexpected IV mismatch %s != %s", old, new)) } -func (pokemon *Pokemon) setDittoAttributes(mode string, isDitto bool, old, new *grpc.PokemonScan) { - if isDitto { - log.Debugf("[POKEMON] %d: %s Ditto found %s -> %s", pokemon.Id, mode, old, new) - pokemon.IsDitto = true - pokemon.DisplayPokemonId = null.IntFrom(int64(pokemon.PokemonId)) - pokemon.PokemonId = int16(pogo.HoloPokemonId_DITTO) - } else { - log.Debugf("[POKEMON] %d: %s not Ditto found %s -> %s", pokemon.Id, mode, old, new) +func (pokemon *Pokemon) SetCellId(v null.Int) { + if pokemon.CellId != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("CellId:%s->%s", FormatNull(pokemon.CellId), FormatNull(v))) + } + pokemon.CellId = v + pokemon.dirty = true } } -func (pokemon *Pokemon) resetDittoAttributes(mode string, old, aux, new *grpc.PokemonScan) (*grpc.PokemonScan, error) { - log.Debugf("[POKEMON] %d: %s Ditto was reset %s (%s) -> %s", pokemon.Id, mode, old, aux, new) - pokemon.IsDitto = false - pokemon.DisplayPokemonId = null.NewInt(0, false) - pokemon.PokemonId = int16(pokemon.DisplayPokemonId.Int64) - return new, checkScans(old, new) -} - -// As far as I'm concerned, wild Ditto only depends on species but not costume/gender/form -var dittoDisguises sync.Map -func confirmDitto(scan *grpc.PokemonScan) { - now := time.Now() - lastSeen, exists := dittoDisguises.Swap(scan.Pokemon, now) - if exists { - log.Debugf("[DITTO] Disguise %s reseen after %s", scan, now.Sub(lastSeen.(time.Time))) - } else { - var sb strings.Builder - sb.WriteString("[DITTO] New disguise ") - sb.WriteString(scan.String()) - sb.WriteString(" found. Current disguises ") - dittoDisguises.Range(func(disguise, lastSeen interface{}) bool { - sb.WriteString(strconv.FormatInt(int64(disguise.(int32)), 10)) - sb.WriteString(" (") - sb.WriteString(now.Sub(lastSeen.(time.Time)).String()) - sb.WriteString(") ") - return true - }) - log.Info(sb.String()) +func (pokemon *Pokemon) SetIsEvent(v int8) { + if pokemon.IsEvent != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("IsEvent:%d->%d", pokemon.IsEvent, v)) + } + pokemon.IsEvent = v + pokemon.dirty = true } } -// detectDitto returns the IV/level set that should be used for persisting to db/seen if caught. -// error is set if something unexpected happened and the scan history should be cleared. -func (pokemon *Pokemon) detectDitto(scan *grpc.PokemonScan) (*grpc.PokemonScan, error) { - unboostedScan, boostedScan, strongScan := pokemon.locateAllScans() - if scan.Strong { - if strongScan != nil { - expectedLevel := strongScan.Level - isBoosted := scan.Weather != int32(pogo.GameplayWeatherProto_NONE) - if strongScan.Weather != int32(pogo.GameplayWeatherProto_NONE) != isBoosted { - if isBoosted { - expectedLevel += 5 - } else { - expectedLevel -= 5 - } - } - if scan.Level != expectedLevel || scan.CompressedIv() != strongScan.CompressedIv() { - return scan, errors.New(fmt.Sprintf("Unexpected strong Pokemon (Ditto?), %s -> %s", - strongScan, scan)) - } +func (pokemon *Pokemon) SetShiny(v null.Bool) { + if pokemon.Shiny != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Shiny:%s->%s", FormatNull(pokemon.Shiny), FormatNull(v))) } - return scan, nil + pokemon.Shiny = v + pokemon.dirty = true } +} - // Here comes the Ditto logic. Embrace yourself :) - // Ditto weather can be split into 4 categories: - // - 00: No weather boost - // - 0P: No weather boost but Ditto is actually boosted by partly cloudy causing seen IV to be boosted [atypical] - // - B0: Weather boosts disguise but not Ditto causing seen IV to be unboosted [atypical] - // - PP: Weather being partly cloudy boosts both disguise and Ditto - // - // We will also use 0N/BN/PN to denote a normal non-Ditto spawn with corresponding weather boosts. - // Disguise IV depends on Ditto weather boost instead, and caught Ditto is boosted only in PP state. - if pokemon.IsDitto { - var unboostedLevel int32 - if boostedScan != nil { - unboostedLevel = boostedScan.Level - 5 - } else if unboostedScan != nil { - unboostedLevel = unboostedScan.Level - } else { - pokemon.resetDittoAttributes("?", nil, nil, scan) - return scan, errors.New("Missing past scans. Ditto will be reset") - } - // If IsDitto = true, then the IV sets in history are ALWAYS confirmed - scan.Confirmed = true - switch scan.Weather { - case int32(pogo.GameplayWeatherProto_NONE): - if scan.CellWeather == int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { - switch scan.Level { - case unboostedLevel: - return pokemon.resetDittoAttributes("0N", unboostedScan, boostedScan, scan) - case unboostedLevel + 5: - // For a confirmed Ditto, we persist IV in inactive only in 0P state - // when disguise is boosted, it has same IV as Ditto - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return unboostedScan, checkScans(boostedScan, scan) - } - return scan, errors.New(fmt.Sprintf("Unexpected 0P Ditto level change, %s/%s -> %s", - unboostedScan, boostedScan, scan)) - } - return scan, checkScans(unboostedScan, scan) - case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): - return scan, checkScans(boostedScan, scan) - } - switch scan.Level { - case unboostedLevel: - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - return scan, checkScans(unboostedScan, scan) - case unboostedLevel + 5: - return pokemon.resetDittoAttributes("BN", boostedScan, unboostedScan, scan) +func (pokemon *Pokemon) SetCp(v null.Int) { + if pokemon.Cp != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Cp:%s->%s", FormatNull(pokemon.Cp), FormatNull(v))) } - return scan, errors.New(fmt.Sprintf("Unexpected B0 Ditto level change, %s/%s -> %s", - unboostedScan, boostedScan, scan)) + pokemon.Cp = v + pokemon.dirty = true } +} - isBoosted := scan.Weather != int32(pogo.GameplayWeatherProto_NONE) - var matchingScan *grpc.PokemonScan - if unboostedScan != nil || boostedScan != nil { - if unboostedScan != nil && boostedScan != nil { // if we have both IVs then they must be correct - if unboostedScan.Level == scan.Level { - if isBoosted { - pokemon.setDittoAttributes(">B0", true, unboostedScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - return scan, nil - } - return scan, checkScans(unboostedScan, scan) - } else if boostedScan.Level == scan.Level { - if isBoosted { - return scan, checkScans(boostedScan, scan) - } - pokemon.setDittoAttributes(">0P", true, boostedScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return unboostedScan, nil - } - return scan, errors.New(fmt.Sprintf("Unexpected third level found %s, %s vs %s", - unboostedScan, boostedScan, scan)) - } - - levelAdjustment := int32(0) - if isBoosted { - if boostedScan != nil { - matchingScan = boostedScan - } else { - matchingScan = unboostedScan - levelAdjustment = 5 - } - } else { - if unboostedScan != nil { - matchingScan = unboostedScan - } else { - matchingScan = boostedScan - levelAdjustment = -5 - } - } - // There are 10 total possible transitions among these states, i.e. all 12 of them except for 0P <-> PP. - // A Ditto in 00/PP state is undetectable. We try to detect them in the remaining possibilities. - // Now we try to detect all 10 possible conditions where we could identify Ditto with certainty - switch scan.Level - (matchingScan.Level + levelAdjustment) { - case 0: - // the Pokémon has been encountered before, but we find an unexpected level when reencountering it => Ditto - // note that at this point the level should have been already readjusted according to the new weather boost - case 5: - switch scan.Weather { - case int32(pogo.GameplayWeatherProto_NONE): - switch matchingScan.Weather { - case int32(pogo.GameplayWeatherProto_NONE): - pokemon.setDittoAttributes("00/0N>0P", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return unboostedScan, nil - case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): - if err := checkScans(matchingScan, scan); err != nil { - return scan, err - } - pokemon.setDittoAttributes("PN>0P", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Confirmed = true - return unboostedScan, nil - } - if err := checkScans(matchingScan, scan); err != nil { - return scan, err - } - if scan.CellWeather != int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { - if scan.MustHaveRerolled(matchingScan) { - pokemon.setDittoAttributes("B0>00/[0N]", false, matchingScan, scan) - } else { - // set Ditto as it is most likely B0>00 if species did not reroll - pokemon.setDittoAttributes("B0>[00]/0N", true, matchingScan, scan) - } - scan.Confirmed = true - } else if matchingScan.Confirmed || scan.MustBeBoosted() { - pokemon.setDittoAttributes("BN>0P", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Confirmed = true - return unboostedScan, nil - // scan.MustBeUnboosted() need not be checked since matchingScan would not have been in B0 - } else { - // in case of BN>0P, we set Ditto to be a hidden 0P state, hoping we rediscover later - // setting 0P Ditto would also mean that we have a Ditto with unconfirmed IV which is a bad idea - if _, possible := dittoDisguises.Load(scan.Pokemon); possible { - if _, possible := dittoDisguises.Load(matchingScan.Pokemon); !possible { - // this guess is most likely to be correct except when Ditto pool just rerolled - pokemon.setDittoAttributes("BN>[0P] or B0>0N", true, matchingScan, scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return unboostedScan, nil - } - } - pokemon.setDittoAttributes("BN>0P or B0>[0N]", false, matchingScan, scan) - } - matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) - case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): - // we can never be sure if this is a Ditto or rerolling into non-Ditto - if scan.MustHaveRerolled(matchingScan) { - pokemon.setDittoAttributes("B0>PP/[PN]", false, matchingScan, scan) - } else { - pokemon.setDittoAttributes("B0>[PP]/PN", true, matchingScan, scan) - } - matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) - default: - pokemon.setDittoAttributes("B0>BN", false, matchingScan, scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) - } - return scan, nil - case -5: - switch scan.Weather { - case int32(pogo.GameplayWeatherProto_NONE): - // we can never be sure if this is a Ditto or rerolling into non-Ditto - if scan.MustHaveRerolled(matchingScan) { - pokemon.setDittoAttributes("0P>00/[0N]", false, matchingScan, scan) - } else { - pokemon.setDittoAttributes("0P>[00]/0N", true, matchingScan, scan) - } - matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return scan, nil - case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): - pokemon.setDittoAttributes("0P>PN", false, matchingScan, scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Confirmed = true - return scan, checkScans(matchingScan, scan) - } - if matchingScan.Weather != int32(pogo.GameplayWeatherProto_NONE) { - pokemon.setDittoAttributes("BN/PP/PN>B0", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - return scan, nil - } - if err := checkScans(matchingScan, scan); err != nil { - return scan, err - } - if scan.MustBeBoosted() { - pokemon.setDittoAttributes("0P>BN", false, matchingScan, scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Confirmed = true - } else if matchingScan.Confirmed || // this covers scan.MustBeUnboosted() - matchingScan.CellWeather != int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { - pokemon.setDittoAttributes("00/0N>B0", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - scan.Confirmed = true - } else { - // same rationale as BN>0P or B0>[0N] - if _, possible := dittoDisguises.Load(scan.Pokemon); possible { - if _, possible := dittoDisguises.Load(matchingScan.Pokemon); !possible { - // this guess is most likely to be correct except when Ditto pool just rerolled - pokemon.setDittoAttributes("0N>[B0] or 0P>BN", true, matchingScan, scan) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - return scan, nil - } - } - pokemon.setDittoAttributes("0N>B0 or 0P>[BN]", false, matchingScan, scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - } - return scan, nil - case 10: - pokemon.setDittoAttributes("B0>0P", true, matchingScan, scan) - confirmDitto(scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - return matchingScan, nil // unboostedScan is a wrong guess in this case - case -10: - pokemon.setDittoAttributes("0P>B0", true, matchingScan, scan) - confirmDitto(scan) - matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - return scan, nil - default: - return scan, errors.New(fmt.Sprintf("Unexpected level %s -> %s", matchingScan, scan)) - } - } - if isBoosted { - if scan.MustBeUnboosted() { - pokemon.setDittoAttributes("B0", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_NONE) - scan.Confirmed = true - return scan, checkScans(unboostedScan, scan) +func (pokemon *Pokemon) SetLevel(v null.Int) { + if pokemon.Level != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Level:%s->%s", FormatNull(pokemon.Level), FormatNull(v))) } - scan.Confirmed = scan.MustBeBoosted() - return scan, checkScans(boostedScan, scan) - } else if scan.MustBeBoosted() { - pokemon.setDittoAttributes("0P", true, matchingScan, scan) - confirmDitto(scan) - scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - scan.Confirmed = true - return unboostedScan, checkScans(boostedScan, scan) + pokemon.Level = v + pokemon.dirty = true } - scan.Confirmed = scan.MustBeUnboosted() - return scan, checkScans(unboostedScan, scan) } -func (pokemon *Pokemon) clearIv(cp bool) { - pokemon.AtkIv = null.NewInt(0, false) - pokemon.DefIv = null.NewInt(0, false) - pokemon.StaIv = null.NewInt(0, false) - pokemon.Iv = null.NewFloat(0, false) - if cp { - switch pokemon.SeenType.ValueOrZero() { - case SeenType_LureEncounter: - pokemon.SeenType = null.StringFrom(SeenType_LureWild) - case SeenType_Encounter: - pokemon.SeenType = null.StringFrom(SeenType_Wild) +func (pokemon *Pokemon) SetMove1(v null.Int) { + if pokemon.Move1 != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Move1:%s->%s", FormatNull(pokemon.Move1), FormatNull(v))) } - pokemon.Cp = null.NewInt(0, false) - pokemon.Pvp = null.NewString("", false) + pokemon.Move1 = v + pokemon.dirty = true } } -// caller should setPokemonDisplay prior to calling this -func (pokemon *Pokemon) addEncounterPokemon(ctx context.Context, db db.DbDetails, proto *pogo.PokemonProto, username string) { - pokemon.Username = null.StringFrom(username) - pokemon.Shiny = null.BoolFrom(proto.PokemonDisplay.Shiny) - pokemon.Cp = null.IntFrom(int64(proto.Cp)) - pokemon.Move1 = null.IntFrom(int64(proto.Move1)) - pokemon.Move2 = null.IntFrom(int64(proto.Move2)) - pokemon.Height = null.FloatFrom(float64(proto.HeightM)) - pokemon.Size = null.IntFrom(int64(proto.Size)) - pokemon.Weight = null.FloatFrom(float64(proto.WeightKg)) - - scan := grpc.PokemonScan{ - Weather: int32(pokemon.Weather.Int64), - Strong: pokemon.IsStrong.Bool, - Attack: proto.IndividualAttack, - Defense: proto.IndividualDefense, - Stamina: proto.IndividualStamina, - CellWeather: int32(pokemon.Weather.Int64), - Pokemon: int32(proto.PokemonId), - Costume: int32(proto.PokemonDisplay.Costume), - Gender: int32(proto.PokemonDisplay.Gender), - Form: int32(proto.PokemonDisplay.Form), - } - if scan.CellWeather == int32(pogo.GameplayWeatherProto_NONE) { - weather, err := getWeatherRecord(ctx, db, weatherCellIdFromLatLon(pokemon.Lat, pokemon.Lon)) - if err != nil || weather == nil || !weather.GameplayCondition.Valid { - log.Warnf("Failed to obtain weather for Pokemon %d: %s", pokemon.Id, err) - } else { - scan.CellWeather = int32(weather.GameplayCondition.Int64) +func (pokemon *Pokemon) SetMove2(v null.Int) { + if pokemon.Move2 != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Move2:%s->%s", FormatNull(pokemon.Move2), FormatNull(v))) } + pokemon.Move2 = v + pokemon.dirty = true } - if proto.CpMultiplier < 0.734 { - scan.Level = int32((58.215688455154954*proto.CpMultiplier-2.7012478057856497)*proto.CpMultiplier + 1.3220677708486794) - } else if proto.CpMultiplier < .795 { - scan.Level = int32(171.34093607855277*proto.CpMultiplier - 94.95626666368578) - } else { - scan.Level = int32(199.99995231630976*proto.CpMultiplier - 117.55996066890287) - } +} - caughtIv, err := pokemon.detectDitto(&scan) - if err != nil { - caughtIv = &scan - log.Errorf("[POKEMON] Unexpected %d: %s", pokemon.Id, err) - } - if caughtIv == nil { // this can only happen for a 0P Ditto - pokemon.Level = null.IntFrom(int64(scan.Level - 5)) - pokemon.clearIv(false) - } else { - pokemon.Level = null.IntFrom(int64(caughtIv.Level)) - pokemon.calculateIv(int64(caughtIv.Attack), int64(caughtIv.Defense), int64(caughtIv.Stamina)) - } - if err == nil { - newScans := make([]*grpc.PokemonScan, len(pokemon.internal.ScanHistory)+1) - entriesCount := 0 - for _, oldEntry := range pokemon.internal.ScanHistory { - if oldEntry.Strong != scan.Strong || !oldEntry.Strong && - oldEntry.Weather == int32(pogo.GameplayWeatherProto_NONE) != - (scan.Weather == int32(pogo.GameplayWeatherProto_NONE)) { - newScans[entriesCount] = oldEntry - entriesCount++ - } +func (pokemon *Pokemon) SetHeight(v null.Float) { + if !nullFloatAlmostEqual(pokemon.Height, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Height:%s->%s", FormatNull(pokemon.Height), FormatNull(v))) } - newScans[entriesCount] = &scan - pokemon.internal.ScanHistory = newScans[:entriesCount+1] - } else { - // undo possible changes - scan.Confirmed = false - scan.Weather = int32(pokemon.Weather.Int64) - pokemon.internal.ScanHistory = make([]*grpc.PokemonScan, 1) - pokemon.internal.ScanHistory[0] = &scan + pokemon.Height = v + pokemon.dirty = true } } -func (pokemon *Pokemon) updatePokemonFromEncounterProto(ctx context.Context, db db.DbDetails, encounterData *pogo.EncounterOutProto, username string, timestampMs int64) { - pokemon.IsEvent = 0 - pokemon.addWildPokemon(ctx, db, encounterData.Pokemon, timestampMs, false) - // tappable encounter can also be available in seen as normal encounter once tapped - if pokemon.isSeenFromTappable() { - pokemon.SeenType = null.StringFrom(SeenType_Encounter) - } - pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon.Pokemon, username) - - if pokemon.CellId.Valid == false { - centerCoord := s2.LatLngFromDegrees(pokemon.Lat, pokemon.Lon) - cellID := s2.CellIDFromLatLng(centerCoord).Parent(15) - pokemon.CellId = null.IntFrom(int64(cellID)) +func (pokemon *Pokemon) SetWeight(v null.Float) { + if !nullFloatAlmostEqual(pokemon.Weight, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Weight:%s->%s", FormatNull(pokemon.Weight), FormatNull(v))) + } + pokemon.Weight = v + pokemon.dirty = true } } -func (pokemon *Pokemon) isSeenFromTappable() bool { - return pokemon.SeenType.ValueOrZero() != SeenType_TappableEncounter && pokemon.SeenType.ValueOrZero() != SeenType_TappableLureEncounter -} - -func (pokemon *Pokemon) updatePokemonFromDiskEncounterProto(ctx context.Context, db db.DbDetails, encounterData *pogo.DiskEncounterOutProto, username string) { - pokemon.IsEvent = 0 - pokemon.setPokemonDisplay(int16(encounterData.Pokemon.PokemonId), encounterData.Pokemon.PokemonDisplay) - pokemon.SeenType = null.StringFrom(SeenType_LureEncounter) - pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon, username) -} - -func (pokemon *Pokemon) updatePokemonFromTappableEncounterProto(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, encounterData *pogo.TappableEncounterProto, username string, timestampMs int64) { - pokemon.IsEvent = 0 - pokemon.Lat = request.LocationHintLat - pokemon.Lon = request.LocationHintLng - - if spawnpointId := request.GetLocation().GetSpawnpointId(); spawnpointId != "" { - pokemon.SeenType = null.StringFrom(SeenType_TappableEncounter) - - spawnId, err := strconv.ParseInt(spawnpointId, 16, 64) - if err != nil { - panic(err) +func (pokemon *Pokemon) SetSize(v null.Int) { + if pokemon.Size != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Size:%s->%s", FormatNull(pokemon.Size), FormatNull(v))) } - - pokemon.SpawnId = null.IntFrom(spawnId) - pokemon.setExpireTimestampFromSpawnpoint(ctx, db, timestampMs, false) - } else if fortId := request.GetLocation().GetFortId(); fortId != "" { - pokemon.SeenType = null.StringFrom(SeenType_TappableLureEncounter) - - pokemon.PokestopId = null.StringFrom(fortId) - // we don't know any despawn times from lured/fort tappables - pokemon.ExpireTimestamp = null.IntFrom(int64(timestampMs)/1000 + int64(120)) - pokemon.ExpireTimestampVerified = false - } - if !pokemon.Username.Valid { - pokemon.Username = null.StringFrom(username) + pokemon.Size = v + pokemon.dirty = true } - pokemon.setPokemonDisplay(int16(encounterData.Pokemon.PokemonId), encounterData.Pokemon.PokemonDisplay) - pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon, username) } -func (pokemon *Pokemon) setPokemonDisplay(pokemonId int16, display *pogo.PokemonDisplayProto) { - if !pokemon.isNewRecord() { - // If we would like to support detect A/B spawn in the future, fill in more code here from Chuck - var oldId int16 - if pokemon.IsDitto { - oldId = int16(pokemon.DisplayPokemonId.ValueOrZero()) - } else { - oldId = pokemon.PokemonId +func (pokemon *Pokemon) SetIsDitto(v bool) { + if pokemon.IsDitto != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("IsDitto:%t->%t", pokemon.IsDitto, v)) } - if oldId != pokemonId || pokemon.Form != null.IntFrom(int64(display.Form)) || - pokemon.Costume != null.IntFrom(int64(display.Costume)) || - pokemon.Gender != null.IntFrom(int64(display.Gender)) || - pokemon.IsStrong.ValueOrZero() != display.IsStrongPokemon { - log.Debugf("Pokemon %d changed from (%d,%d,%d,%d,%t) to (%d,%d,%d,%d,%t)", pokemon.Id, oldId, - pokemon.Form.ValueOrZero(), pokemon.Costume.ValueOrZero(), pokemon.Gender.ValueOrZero(), - pokemon.IsStrong.ValueOrZero(), - pokemonId, display.Form, display.Costume, display.Gender, display.IsStrongPokemon) - pokemon.Weight = null.NewFloat(0, false) - pokemon.Height = null.NewFloat(0, false) - pokemon.Size = null.NewInt(0, false) - pokemon.Move1 = null.NewInt(0, false) - pokemon.Move2 = null.NewInt(0, false) - pokemon.Cp = null.NewInt(0, false) - pokemon.Shiny = null.NewBool(false, false) - pokemon.IsDitto = false - pokemon.DisplayPokemonId = null.NewInt(0, false) - pokemon.Pvp = null.NewString("", false) - } - } - if pokemon.isNewRecord() || !pokemon.IsDitto { - pokemon.PokemonId = pokemonId - } - pokemon.Gender = null.IntFrom(int64(display.Gender)) - pokemon.Form = null.IntFrom(int64(display.Form)) - pokemon.Costume = null.IntFrom(int64(display.Costume)) - if !pokemon.isNewRecord() { - pokemon.repopulateIv(int64(display.WeatherBoostedCondition), display.IsStrongPokemon) + pokemon.IsDitto = v + pokemon.dirty = true } - pokemon.Weather = null.IntFrom(int64(display.WeatherBoostedCondition)) - pokemon.IsStrong = null.BoolFrom(display.IsStrongPokemon) } -func (pokemon *Pokemon) repopulateIv(weather int64, isStrong bool) { - var isBoosted bool - if !pokemon.IsDitto { - isBoosted = weather != int64(pogo.GameplayWeatherProto_NONE) - if isStrong == pokemon.IsStrong.ValueOrZero() && - pokemon.Weather.ValueOrZero() != int64(pogo.GameplayWeatherProto_NONE) == isBoosted { - return - } - } else if isStrong { - log.Errorf("Strong Ditto??? I can't handle this fml %d", pokemon.Id) - pokemon.clearIv(true) - return - } else { - isBoosted = weather == int64(pogo.GameplayWeatherProto_PARTLY_CLOUDY) - // both Ditto and disguise are boosted and Ditto was not boosted: none -> boosted - // or both Ditto and disguise were boosted and Ditto is not boosted: boosted -> none - if pokemon.Weather.ValueOrZero() == int64(pogo.GameplayWeatherProto_PARTLY_CLOUDY) == isBoosted { - return - } - } - matchingScan, isBoostedMatches := pokemon.locateScan(isStrong, isBoosted) - var oldAtk, oldDef, oldSta int64 - if matchingScan == nil { - pokemon.Level = null.NewInt(0, false) - pokemon.clearIv(true) - } else { - oldLevel := pokemon.Level.ValueOrZero() - if pokemon.AtkIv.Valid { - oldAtk = pokemon.AtkIv.Int64 - oldDef = pokemon.DefIv.Int64 - oldSta = pokemon.StaIv.Int64 - } else { - oldAtk = -1 - oldDef = -1 - oldSta = -1 - } - pokemon.Level = null.IntFrom(int64(matchingScan.Level)) - if isBoostedMatches || isStrong { // strong Pokemon IV is unaffected by weather - pokemon.calculateIv(int64(matchingScan.Attack), int64(matchingScan.Defense), int64(matchingScan.Stamina)) - switch pokemon.SeenType.ValueOrZero() { - case SeenType_LureWild: - pokemon.SeenType = null.StringFrom(SeenType_LureEncounter) - case SeenType_Wild: - pokemon.SeenType = null.StringFrom(SeenType_Encounter) - } - } else { - pokemon.clearIv(true) - } - if !isBoostedMatches { - if isBoosted { - pokemon.Level.Int64 += 5 - } else { - pokemon.Level.Int64 -= 5 - } - } - if pokemon.Level.Int64 != oldLevel || pokemon.AtkIv.Valid && - (pokemon.AtkIv.Int64 != oldAtk || pokemon.DefIv.Int64 != oldDef || pokemon.StaIv.Int64 != oldSta) { - pokemon.Cp = null.NewInt(0, false) - pokemon.Pvp = null.NewString("", false) +func (pokemon *Pokemon) SetDisplayPokemonId(v null.Int) { + if pokemon.DisplayPokemonId != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("DisplayPokemonId:%s->%s", FormatNull(pokemon.DisplayPokemonId), FormatNull(v))) } + pokemon.DisplayPokemonId = v + pokemon.dirty = true } } -func (pokemon *Pokemon) recomputeCpIfNeeded(ctx context.Context, db db.DbDetails, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition) { - if pokemon.Cp.Valid || ohbem == nil { - return - } - var displayPokemon int - shouldOverrideIv := false - var overrideIv *grpc.PokemonScan - if pokemon.IsDitto { - displayPokemon = int(pokemon.DisplayPokemonId.Int64) - if pokemon.Weather.Int64 == int64(pogo.GameplayWeatherProto_NONE) { - cellId := weatherCellIdFromLatLon(pokemon.Lat, pokemon.Lon) - cellWeather, found := weather[cellId] - if !found { - record, err := getWeatherRecord(ctx, db, cellId) - if err != nil || record == nil || !record.GameplayCondition.Valid { - log.Warnf("[POKEMON] Failed to obtain weather for Pokemon %d: %s", pokemon.Id, err) - } else { - log.Warnf("[POKEMON] Weather not found locally for %d at %d", pokemon.Id, cellId) - cellWeather = pogo.GameplayWeatherProto_WeatherCondition(record.GameplayCondition.Int64) - found = true - } - } - if found && cellWeather == pogo.GameplayWeatherProto_PARTLY_CLOUDY { - shouldOverrideIv = true - scan, isBoostedMatches := pokemon.locateScan(false, false) - if scan != nil && isBoostedMatches { - overrideIv = scan - } - } +func (pokemon *Pokemon) SetPvp(v null.String) { + if pokemon.Pvp != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Pvp:%s->%s", FormatNull(pokemon.Pvp), FormatNull(v))) } - } else { - displayPokemon = int(pokemon.PokemonId) - } - var cp int - var err error - if shouldOverrideIv { - if overrideIv == nil { - return - } - // You should see boosted IV for 0P Ditto - cp, err = ohbem.CalculateCp(displayPokemon, int(pokemon.Form.ValueOrZero()), 0, - int(overrideIv.Attack), int(overrideIv.Defense), int(overrideIv.Stamina), float64(overrideIv.Level)) - } else { - if !pokemon.AtkIv.Valid || !pokemon.Level.Valid { - return - } - cp, err = ohbem.CalculateCp(displayPokemon, int(pokemon.Form.ValueOrZero()), 0, - int(pokemon.AtkIv.Int64), int(pokemon.DefIv.Int64), int(pokemon.StaIv.Int64), - float64(pokemon.Level.Int64)) - } - if err == nil { - pokemon.Cp = null.IntFrom(int64(cp)) - } else { - log.Warnf("Pokemon %d %d CP unset due to error %s", pokemon.Id, displayPokemon, err) + pokemon.Pvp = v + pokemon.dirty = true } } -func UpdatePokemonRecordWithEncounterProto(ctx context.Context, db db.DbDetails, encounter *pogo.EncounterOutProto, username string, timestamp int64) string { - if encounter.Pokemon == nil { - return "No encounter" - } - - encounterId := encounter.Pokemon.EncounterId - - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - defer pokemonMutex.Unlock() - - pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId) - if err != nil { - log.Errorf("Error pokemon [%d]: %s", encounterId, err) - return fmt.Sprintf("Error finding pokemon %s", err) +func (pokemon *Pokemon) SetCapture1(v null.Float) { + if !nullFloatAlmostEqual(pokemon.Capture1, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Capture1:%s->%s", FormatNull(pokemon.Capture1), FormatNull(v))) + } + pokemon.Capture1 = v + pokemon.dirty = true } - - pokemon.updatePokemonFromEncounterProto(ctx, db, encounter, username, timestamp) - savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, timestamp/1000) - // updateEncounterStats() should only be called for encounters, and called - // even if we have the pokemon record already. - updateEncounterStats(pokemon) - - return fmt.Sprintf("%d %d Pokemon %d CP%d", encounter.Pokemon.EncounterId, encounterId, pokemon.PokemonId, encounter.Pokemon.Pokemon.Cp) } -func UpdatePokemonRecordWithDiskEncounterProto(ctx context.Context, db db.DbDetails, encounter *pogo.DiskEncounterOutProto, username string) string { - if encounter.Pokemon == nil { - return "No encounter" +func (pokemon *Pokemon) SetCapture2(v null.Float) { + if !nullFloatAlmostEqual(pokemon.Capture2, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Capture2:%s->%s", FormatNull(pokemon.Capture2), FormatNull(v))) + } + pokemon.Capture2 = v + pokemon.dirty = true } +} - encounterId := uint64(encounter.Pokemon.PokemonDisplay.DisplayId) - - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - defer pokemonMutex.Unlock() - - pokemon, err := getPokemonRecord(ctx, db, encounterId) - if err != nil { - log.Errorf("Error pokemon [%d]: %s", encounterId, err) - return fmt.Sprintf("Error finding pokemon %s", err) +func (pokemon *Pokemon) SetCapture3(v null.Float) { + if !nullFloatAlmostEqual(pokemon.Capture3, v, floatTolerance) { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Capture3:%s->%s", FormatNull(pokemon.Capture3), FormatNull(v))) + } + pokemon.Capture3 = v + pokemon.dirty = true } +} - if pokemon == nil || pokemon.isNewRecord() { - // No pokemon found - diskEncounterCache.Set(encounterId, encounter, ttlcache.DefaultTTL) - return fmt.Sprintf("%d Disk encounter without previous GMO - Pokemon stored for later", encounterId) +func (pokemon *Pokemon) SetUpdated(v null.Int) { + if pokemon.Updated != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Updated:%s->%s", FormatNull(pokemon.Updated), FormatNull(v))) + } + pokemon.Updated = v + pokemon.dirty = true } - pokemon.updatePokemonFromDiskEncounterProto(ctx, db, encounter, username) - savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, time.Now().Unix()) - // updateEncounterStats() should only be called for encounters, and called - // even if we have the pokemon record already. - updateEncounterStats(pokemon) - - return fmt.Sprintf("%d Disk Pokemon %d CP%d", encounterId, pokemon.PokemonId, encounter.Pokemon.Cp) } -func UpdatePokemonRecordWithTappableEncounter(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, encounter *pogo.TappableEncounterProto, username string, timestampMs int64) string { - encounterId := request.GetEncounterId() - - pokemonMutex, _ := pokemonStripedMutex.GetLock(encounterId) - pokemonMutex.Lock() - defer pokemonMutex.Unlock() - - pokemon, err := getOrCreatePokemonRecord(ctx, db, encounterId) - if err != nil { - log.Errorf("Error pokemon [%d]: %s", encounterId, err) - return fmt.Sprintf("Error finding pokemon %s", err) +func (pokemon *Pokemon) SetChanged(v int64) { + if pokemon.Changed != v { + if dbDebugEnabled { + pokemon.changedFields = append(pokemon.changedFields, fmt.Sprintf("Changed:%d->%d", pokemon.Changed, v)) + } + pokemon.Changed = v + pokemon.dirty = true } - pokemon.updatePokemonFromTappableEncounterProto(ctx, db, request, encounter, username, timestampMs) - savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, time.Now().Unix()) - // updateEncounterStats() should only be called for encounters, and called - // even if we have the pokemon record already. - updateEncounterStats(pokemon) - - return fmt.Sprintf("%d Tappable Pokemon %d CP%d", encounterId, pokemon.PokemonId, encounter.Pokemon.Cp) } diff --git a/decoder/pokemonRtree.go b/decoder/pokemonRtree.go index 89e1edde..16dff824 100644 --- a/decoder/pokemonRtree.go +++ b/decoder/pokemonRtree.go @@ -8,11 +8,11 @@ import ( "golbat/config" "github.com/UnownHash/gohbem" + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" "github.com/puzpuzpuz/xsync/v3" log "github.com/sirupsen/logrus" "github.com/tidwall/rtree" - "gopkg.in/guregu/null.v4" ) type PokemonLookupCacheItem struct { @@ -50,14 +50,12 @@ var pokemonTree rtree.RTreeG[uint64] func initPokemonRtree() { pokemonLookupCache = xsync.NewMapOf[uint64, PokemonLookupCacheItem]() - // Set up OnEviction callbacks for each cache in the array - for i := range pokemonCache { - pokemonCache[i].OnEviction(func(ctx context.Context, ev ttlcache.EvictionReason, v *ttlcache.Item[uint64, Pokemon]) { - r := v.Value() - removePokemonFromTree(&r) - // Rely on the pokemon pvp lookup caches to remove themselves rather than trying to synchronise - }) - } + // Set up OnEviction callback on all shards + pokemonCache.OnEviction(func(ctx context.Context, ev ttlcache.EvictionReason, v *ttlcache.Item[uint64, *Pokemon]) { + pokemon := v.Value() + removePokemonFromTree(pokemon.Id, pokemon.Lat, pokemon.Lon) + // Rely on the pokemon pvp lookup caches to remove themselves rather than trying to synchronise + }) } func pokemonRtreeUpdatePokemonOnGet(pokemon *Pokemon) { @@ -160,16 +158,15 @@ func addPokemonToTree(pokemon *Pokemon) { pokemonTreeMutex.Unlock() } -func removePokemonFromTree(pokemon *Pokemon) { - pokemonId := pokemon.Id +func removePokemonFromTree(pokemonId uint64, lat, lon float64) { pokemonTreeMutex.Lock() beforeLen := pokemonTree.Len() - pokemonTree.Delete([2]float64{pokemon.Lon, pokemon.Lat}, [2]float64{pokemon.Lon, pokemon.Lat}, pokemonId) + pokemonTree.Delete([2]float64{lon, lat}, [2]float64{lon, lat}, pokemonId) afterLen := pokemonTree.Len() pokemonTreeMutex.Unlock() pokemonLookupCache.Delete(pokemonId) if beforeLen != afterLen+1 { - log.Infof("PokemonRtree - UNEXPECTED removing %d, lat %f lon %f size %d->%d Map Len %d", pokemonId, pokemon.Lat, pokemon.Lon, beforeLen, afterLen, pokemonLookupCache.Size()) + log.Infof("PokemonRtree - UNEXPECTED removing %d, lat %f lon %f size %d->%d Map Len %d", pokemonId, lat, lon, beforeLen, afterLen, pokemonLookupCache.Size()) } } diff --git a/decoder/pokemon_decode.go b/decoder/pokemon_decode.go new file mode 100644 index 00000000..e29de264 --- /dev/null +++ b/decoder/pokemon_decode.go @@ -0,0 +1,968 @@ +package decoder + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "golbat/db" + "golbat/grpc" + "golbat/pogo" + + "github.com/golang/geo/s2" + "github.com/guregu/null/v6" + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" +) + +func (pokemon *Pokemon) populateInternal() { + if len(pokemon.GolbatInternal) == 0 || len(pokemon.internal.ScanHistory) != 0 { + return + } + err := proto.Unmarshal(pokemon.GolbatInternal, &pokemon.internal) + if err != nil { + log.Warnf("Failed to parse internal data for %d: %s", pokemon.Id, err) + pokemon.internal.Reset() + } +} + +func (pokemon *Pokemon) locateScan(isStrong bool, isBoosted bool) (*grpc.PokemonScan, bool) { + pokemon.populateInternal() + var bestMatching *grpc.PokemonScan + for _, entry := range pokemon.internal.ScanHistory { + if entry.Strong != isStrong { + continue + } + if entry.Weather != int32(pogo.GameplayWeatherProto_NONE) == isBoosted { + return entry, true + } else { + bestMatching = entry + } + } + return bestMatching, false +} + +func (pokemon *Pokemon) locateAllScans() (unboosted, boosted, strong *grpc.PokemonScan) { + pokemon.populateInternal() + for _, entry := range pokemon.internal.ScanHistory { + if entry.Strong { + strong = entry + } else if entry.Weather != int32(pogo.GameplayWeatherProto_NONE) { + boosted = entry + } else { + unboosted = entry + } + } + return +} + +func (pokemon *Pokemon) isNewRecord() bool { + return pokemon.newRecord +} + +func (pokemon *Pokemon) remainingDuration(now int64) time.Duration { + remaining := ttlcache.DefaultTTL + if pokemon.ExpireTimestampVerified { + timeLeft := 60 + pokemon.ExpireTimestamp.ValueOrZero() - now + if timeLeft > 1 { + remaining = time.Duration(timeLeft) * time.Second + } + } + return remaining +} + +func (pokemon *Pokemon) addWildPokemon(ctx context.Context, db db.DbDetails, wildPokemon *pogo.WildPokemonProto, timestampMs int64, trustworthyTimestamp bool) { + if wildPokemon.EncounterId != pokemon.Id { + panic("Unmatched EncounterId") + } + pokemon.SetLat(wildPokemon.Latitude) + pokemon.SetLon(wildPokemon.Longitude) + + spawnId, err := strconv.ParseInt(wildPokemon.SpawnPointId, 16, 64) + if err != nil { + panic(err) + } + pokemon.SetSpawnId(null.IntFrom(spawnId)) + + pokemon.setExpireTimestampFromSpawnpoint(ctx, db, timestampMs, trustworthyTimestamp) + pokemon.setPokemonDisplay(int16(wildPokemon.Pokemon.PokemonId), wildPokemon.Pokemon.PokemonDisplay) +} + +// wildSignificantUpdate returns true if the wild pokemon is significantly different from the current pokemon and +// should be written. +func (pokemon *Pokemon) wildSignificantUpdate(wildPokemon *pogo.WildPokemonProto, time int64) bool { + pokemonDisplay := wildPokemon.Pokemon.PokemonDisplay + // We would accept a wild update if the pokemon has changed; or to extend an unknown spawn time that is expired + + return pokemon.SeenType.ValueOrZero() == SeenType_Cell || + pokemon.SeenType.ValueOrZero() == SeenType_NearbyStop || + pokemon.PokemonId != int16(wildPokemon.Pokemon.PokemonId) || + pokemon.Form.ValueOrZero() != int64(pokemonDisplay.Form) || + pokemon.Weather.ValueOrZero() != int64(pokemonDisplay.WeatherBoostedCondition) || + pokemon.Costume.ValueOrZero() != int64(pokemonDisplay.Costume) || + pokemon.Gender.ValueOrZero() != int64(pokemonDisplay.Gender) || + (!pokemon.ExpireTimestampVerified && pokemon.ExpireTimestamp.ValueOrZero() < time) +} + +// nearbySignificantUpdate returns true if the wild pokemon is significantly different from the current pokemon and +// should be written. +func (pokemon *Pokemon) nearbySignificantUpdate(wildPokemon *pogo.NearbyPokemonProto, time int64) bool { + pokemonDisplay := wildPokemon.PokemonDisplay + // We would accept a wild update if the pokemon has changed; or to extend an unknown spawn time that is expired + + pokemonChanged := pokemon.PokemonId != int16(pokemonDisplay.DisplayId) || + pokemon.Form.ValueOrZero() != int64(pokemonDisplay.Form) || + pokemon.Weather.ValueOrZero() != int64(pokemonDisplay.WeatherBoostedCondition) || + pokemon.Costume.ValueOrZero() != int64(pokemonDisplay.Costume) || + pokemon.Gender.ValueOrZero() != int64(pokemonDisplay.Gender) + + if pokemonChanged { + return true + } + + hasExpired := (!pokemon.ExpireTimestampVerified && pokemon.ExpireTimestamp.ValueOrZero() < time) + + if hasExpired { + return true + } + + if pokemon.SeenType.ValueOrZero() == SeenType_Cell { + return true + } + + // if it's at a nearby stop, or encounter and no other details have changed update is not worthwhile + return false +} + +func (pokemon *Pokemon) updateFromWild(ctx context.Context, db db.DbDetails, wildPokemon *pogo.WildPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { + pokemon.SetIsEvent(0) + switch pokemon.SeenType.ValueOrZero() { + case "", SeenType_Cell, SeenType_NearbyStop: + pokemon.SetSeenType(null.StringFrom(SeenType_Wild)) + } + pokemon.addWildPokemon(ctx, db, wildPokemon, timestampMs, true) + pokemon.recomputeCpIfNeeded(ctx, db, weather) + pokemon.SetUsername(null.StringFrom(username)) + pokemon.SetCellId(null.IntFrom(cellId)) +} + +func (pokemon *Pokemon) updateFromMap(ctx context.Context, db db.DbDetails, mapPokemon *pogo.MapPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { + + if !pokemon.isNewRecord() { + // Do not ever overwrite lure details based on seeing it again in the GMO + return + } + + pokemon.SetIsEvent(0) + + pokemon.Id = mapPokemon.EncounterId + + spawnpointId := mapPokemon.SpawnpointId + + pokestop, unlock, _ := getPokestopRecordReadOnly(ctx, db, spawnpointId) + if pokestop == nil { + // Unrecognised pokestop + return + } + pokemon.SetPokestopId(null.StringFrom(pokestop.Id)) + pokemon.SetLat(pokestop.Lat) + pokemon.SetLon(pokestop.Lon) + pokemon.SetSeenType(null.StringFrom(SeenType_LureWild)) + unlock() + + if mapPokemon.PokemonDisplay != nil { + pokemon.setPokemonDisplay(int16(mapPokemon.PokedexTypeId), mapPokemon.PokemonDisplay) + pokemon.recomputeCpIfNeeded(ctx, db, weather) + // The mapPokemon and nearbyPokemon GMOs don't contain actual shininess. + // shiny = mapPokemon.pokemonDisplay.shiny + } else { + log.Warnf("[POKEMON] MapPokemonProto missing PokemonDisplay for %d", pokemon.Id) + } + if !pokemon.Username.Valid { + pokemon.SetUsername(null.StringFrom(username)) + } + + if mapPokemon.ExpirationTimeMs > 0 && !pokemon.ExpireTimestampVerified { + pokemon.SetExpireTimestamp(null.IntFrom(mapPokemon.ExpirationTimeMs / 1000)) + pokemon.SetExpireTimestampVerified(true) + // if we have cached an encounter for this pokemon, update the TTL. + encounterCache.UpdateTTL(pokemon.Id, pokemon.remainingDuration(timestampMs/1000)) + } else { + pokemon.SetExpireTimestampVerified(false) + } + + pokemon.SetCellId(null.IntFrom(cellId)) +} + +func (pokemon *Pokemon) calculateIv(a int64, d int64, s int64) { + if pokemon.AtkIv.ValueOrZero() != a || pokemon.DefIv.ValueOrZero() != d || pokemon.StaIv.ValueOrZero() != s || + !pokemon.AtkIv.Valid || !pokemon.DefIv.Valid || !pokemon.StaIv.Valid { + pokemon.AtkIv = null.IntFrom(a) + pokemon.DefIv = null.IntFrom(d) + pokemon.StaIv = null.IntFrom(s) + pokemon.Iv = null.FloatFrom(float64(a+d+s) / .45) + pokemon.dirty = true + } +} + +func (pokemon *Pokemon) updateFromNearby(ctx context.Context, db db.DbDetails, nearbyPokemon *pogo.NearbyPokemonProto, cellId int64, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition, timestampMs int64, username string) { + pokemon.SetIsEvent(0) + pokestopId := nearbyPokemon.FortId + pokemon.setPokemonDisplay(int16(nearbyPokemon.PokedexNumber), nearbyPokemon.PokemonDisplay) + pokemon.recomputeCpIfNeeded(ctx, db, weather) + pokemon.SetUsername(null.StringFrom(username)) + + var lat, lon float64 + overrideLatLon := pokemon.isNewRecord() + useCellLatLon := true + if pokestopId != "" { + switch pokemon.SeenType.ValueOrZero() { + case "", SeenType_Cell: + overrideLatLon = true // a better estimate is available + case SeenType_NearbyStop: + default: + return + } + pokestop, unlock, _ := getPokestopRecordReadOnly(ctx, db, pokestopId) + if pokestop == nil { + // Unrecognised pokestop, rollback changes + overrideLatLon = pokemon.isNewRecord() + } else { + pokemon.SetSeenType(null.StringFrom(SeenType_NearbyStop)) + pokemon.SetPokestopId(null.StringFrom(pokestopId)) + lat, lon = pokestop.Lat, pokestop.Lon + useCellLatLon = false + unlock() + } + } + if useCellLatLon { + // Cell Pokemon + if !overrideLatLon && pokemon.SeenType.ValueOrZero() != SeenType_Cell { + // do not downgrade to nearby cell + return + } + + s2cell := s2.CellFromCellID(s2.CellID(cellId)) + lat = s2cell.CapBound().RectBound().Center().Lat.Degrees() + lon = s2cell.CapBound().RectBound().Center().Lng.Degrees() + + pokemon.SetSeenType(null.StringFrom(SeenType_Cell)) + } + if overrideLatLon { + pokemon.SetLat(lat) + pokemon.SetLon(lon) + } else { + midpoint := s2.LatLngFromPoint(s2.Point{s2.PointFromLatLng(s2.LatLngFromDegrees(pokemon.Lat, pokemon.Lon)). + Add(s2.PointFromLatLng(s2.LatLngFromDegrees(lat, lon)).Vector)}) + pokemon.SetLat(midpoint.Lat.Degrees()) + pokemon.SetLon(midpoint.Lng.Degrees()) + } + pokemon.SetCellId(null.IntFrom(cellId)) + pokemon.setUnknownTimestamp(timestampMs / 1000) +} + +const SeenType_Cell string = "nearby_cell" // Pokemon was seen in a cell (without accurate location) +const SeenType_NearbyStop string = "nearby_stop" // Pokemon was seen at a nearby Pokestop, location set to lon, lat of pokestop +const SeenType_Wild string = "wild" // Pokemon was seen in the wild, accurate location but with no IV details +const SeenType_Encounter string = "encounter" // Pokemon has been encountered giving exact details of current IV +const SeenType_LureWild string = "lure_wild" // Pokemon was seen at a lure +const SeenType_LureEncounter string = "lure_encounter" // Pokemon has been encountered at a lure +const SeenType_TappableEncounter string = "tappable_encounter" // Pokemon has been encountered from tappable +const SeenType_TappableLureEncounter string = "tappable_lure_encounter" // Pokemon has been encountered from a lured tappable + +// setExpireTimestampFromSpawnpoint sets the current Pokemon object ExpireTimeStamp, and ExpireTimeStampVerified from the Spawnpoint +// information held. +// db - the database connection to be used +// timestampMs - the timestamp to be used for calculations +// trustworthyTimestamp - whether this timestamp is fully trustworthy (ie comes from GMO server time) +func (pokemon *Pokemon) setExpireTimestampFromSpawnpoint(ctx context.Context, db db.DbDetails, timestampMs int64, trustworthyTimestamp bool) { + if !trustworthyTimestamp && pokemon.ExpireTimestampVerified { + // If our time is not trustworthy, and we have already set a time from some other source (eg a GMO) + // don't modify it + + return + } + + spawnId := pokemon.SpawnId.ValueOrZero() + if spawnId == 0 { + return + } + + pokemon.ExpireTimestampVerified = false + spawnPoint, unlock, _ := getSpawnpointRecord(ctx, db, spawnId) + if spawnPoint != nil && spawnPoint.DespawnSec.Valid { + despawnSecond := int(spawnPoint.DespawnSec.ValueOrZero()) + unlock() + + date := time.Unix(timestampMs/1000, 0) + secondOfHour := date.Second() + date.Minute()*60 + + despawnOffset := despawnSecond - secondOfHour + if despawnOffset < 0 { + despawnOffset += 3600 + } + pokemon.SetExpireTimestamp(null.IntFrom(int64(timestampMs)/1000 + int64(despawnOffset))) + pokemon.SetExpireTimestampVerified(true) + } else { + if unlock != nil { + unlock() + } + pokemon.setUnknownTimestamp(timestampMs / 1000) + } +} + +func (pokemon *Pokemon) setUnknownTimestamp(now int64) { + if !pokemon.ExpireTimestamp.Valid { + pokemon.SetExpireTimestamp(null.IntFrom(now + 20*60)) // should be configurable, add on 20min + } else { + if pokemon.ExpireTimestamp.Int64 < now { + pokemon.SetExpireTimestamp(null.IntFrom(now + 10*60)) // should be configurable, add on 10min + } + } +} + +func checkScans(old *grpc.PokemonScan, new *grpc.PokemonScan) error { + if old == nil || old.CompressedIv() == new.CompressedIv() { + return nil + } + return errors.New(fmt.Sprintf("Unexpected IV mismatch %s != %s", old, new)) +} + +func (pokemon *Pokemon) setDittoAttributes(mode string, isDitto bool, old, new *grpc.PokemonScan) { + if isDitto { + log.Debugf("[POKEMON] %d: %s Ditto found %s -> %s", pokemon.Id, mode, old, new) + pokemon.SetIsDitto(true) + pokemon.SetDisplayPokemonId(null.IntFrom(int64(pokemon.PokemonId))) + pokemon.SetPokemonId(int16(pogo.HoloPokemonId_DITTO)) + } else { + log.Debugf("[POKEMON] %d: %s not Ditto found %s -> %s", pokemon.Id, mode, old, new) + } +} +func (pokemon *Pokemon) resetDittoAttributes(mode string, old, aux, new *grpc.PokemonScan) (*grpc.PokemonScan, error) { + log.Debugf("[POKEMON] %d: %s Ditto was reset %s (%s) -> %s", pokemon.Id, mode, old, aux, new) + pokemon.SetIsDitto(false) + pokemon.SetDisplayPokemonId(null.NewInt(0, false)) + pokemon.SetPokemonId(int16(pokemon.DisplayPokemonId.Int64)) + return new, checkScans(old, new) +} + +// As far as I'm concerned, wild Ditto only depends on species but not costume/gender/form +var dittoDisguises sync.Map + +func confirmDitto(scan *grpc.PokemonScan) { + now := time.Now() + lastSeen, exists := dittoDisguises.Swap(scan.Pokemon, now) + if exists { + log.Debugf("[DITTO] Disguise %s reseen after %s", scan, now.Sub(lastSeen.(time.Time))) + } else { + var sb strings.Builder + sb.WriteString("[DITTO] New disguise ") + sb.WriteString(scan.String()) + sb.WriteString(" found. Current disguises ") + dittoDisguises.Range(func(disguise, lastSeen interface{}) bool { + sb.WriteString(strconv.FormatInt(int64(disguise.(int32)), 10)) + sb.WriteString(" (") + sb.WriteString(now.Sub(lastSeen.(time.Time)).String()) + sb.WriteString(") ") + return true + }) + log.Info(sb.String()) + } +} + +// detectDitto returns the IV/level set that should be used for persisting to db/seen if caught. +// error is set if something unexpected happened and the scan history should be cleared. +func (pokemon *Pokemon) detectDitto(scan *grpc.PokemonScan) (*grpc.PokemonScan, error) { + unboostedScan, boostedScan, strongScan := pokemon.locateAllScans() + if scan.Strong { + if strongScan != nil { + expectedLevel := strongScan.Level + isBoosted := scan.Weather != int32(pogo.GameplayWeatherProto_NONE) + if strongScan.Weather != int32(pogo.GameplayWeatherProto_NONE) != isBoosted { + if isBoosted { + expectedLevel += 5 + } else { + expectedLevel -= 5 + } + } + if scan.Level != expectedLevel || scan.CompressedIv() != strongScan.CompressedIv() { + return scan, errors.New(fmt.Sprintf("Unexpected strong Pokemon (Ditto?), %s -> %s", + strongScan, scan)) + } + } + return scan, nil + } + + // Here comes the Ditto logic. Embrace yourself :) + // Ditto weather can be split into 4 categories: + // - 00: No weather boost + // - 0P: No weather boost but Ditto is actually boosted by partly cloudy causing seen IV to be boosted [atypical] + // - B0: Weather boosts disguise but not Ditto causing seen IV to be unboosted [atypical] + // - PP: Weather being partly cloudy boosts both disguise and Ditto + // + // We will also use 0N/BN/PN to denote a normal non-Ditto spawn with corresponding weather boosts. + // Disguise IV depends on Ditto weather boost instead, and caught Ditto is boosted only in PP state. + if pokemon.IsDitto { + var unboostedLevel int32 + if boostedScan != nil { + unboostedLevel = boostedScan.Level - 5 + } else if unboostedScan != nil { + unboostedLevel = unboostedScan.Level + } else { + pokemon.resetDittoAttributes("?", nil, nil, scan) + return scan, errors.New("Missing past scans. Ditto will be reset") + } + // If IsDitto = true, then the IV sets in history are ALWAYS confirmed + scan.Confirmed = true + switch scan.Weather { + case int32(pogo.GameplayWeatherProto_NONE): + if scan.CellWeather == int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { + switch scan.Level { + case unboostedLevel: + return pokemon.resetDittoAttributes("0N", unboostedScan, boostedScan, scan) + case unboostedLevel + 5: + // For a confirmed Ditto, we persist IV in inactive only in 0P state + // when disguise is boosted, it has same IV as Ditto + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return unboostedScan, checkScans(boostedScan, scan) + } + return scan, errors.New(fmt.Sprintf("Unexpected 0P Ditto level change, %s/%s -> %s", + unboostedScan, boostedScan, scan)) + } + return scan, checkScans(unboostedScan, scan) + case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): + return scan, checkScans(boostedScan, scan) + } + switch scan.Level { + case unboostedLevel: + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + return scan, checkScans(unboostedScan, scan) + case unboostedLevel + 5: + return pokemon.resetDittoAttributes("BN", boostedScan, unboostedScan, scan) + } + return scan, errors.New(fmt.Sprintf("Unexpected B0 Ditto level change, %s/%s -> %s", + unboostedScan, boostedScan, scan)) + } + + isBoosted := scan.Weather != int32(pogo.GameplayWeatherProto_NONE) + var matchingScan *grpc.PokemonScan + if unboostedScan != nil || boostedScan != nil { + if unboostedScan != nil && boostedScan != nil { // if we have both IVs then they must be correct + if unboostedScan.Level == scan.Level { + if isBoosted { + pokemon.setDittoAttributes(">B0", true, unboostedScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + return scan, nil + } + return scan, checkScans(unboostedScan, scan) + } else if boostedScan.Level == scan.Level { + if isBoosted { + return scan, checkScans(boostedScan, scan) + } + pokemon.setDittoAttributes(">0P", true, boostedScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return unboostedScan, nil + } + return scan, errors.New(fmt.Sprintf("Unexpected third level found %s, %s vs %s", + unboostedScan, boostedScan, scan)) + } + + levelAdjustment := int32(0) + if isBoosted { + if boostedScan != nil { + matchingScan = boostedScan + } else { + matchingScan = unboostedScan + levelAdjustment = 5 + } + } else { + if unboostedScan != nil { + matchingScan = unboostedScan + } else { + matchingScan = boostedScan + levelAdjustment = -5 + } + } + // There are 10 total possible transitions among these states, i.e. all 12 of them except for 0P <-> PP. + // A Ditto in 00/PP state is undetectable. We try to detect them in the remaining possibilities. + // Now we try to detect all 10 possible conditions where we could identify Ditto with certainty + switch scan.Level - (matchingScan.Level + levelAdjustment) { + case 0: + // the Pokémon has been encountered before, but we find an unexpected level when reencountering it => Ditto + // note that at this point the level should have been already readjusted according to the new weather boost + case 5: + switch scan.Weather { + case int32(pogo.GameplayWeatherProto_NONE): + switch matchingScan.Weather { + case int32(pogo.GameplayWeatherProto_NONE): + pokemon.setDittoAttributes("00/0N>0P", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return unboostedScan, nil + case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): + if err := checkScans(matchingScan, scan); err != nil { + return scan, err + } + pokemon.setDittoAttributes("PN>0P", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Confirmed = true + return unboostedScan, nil + } + if err := checkScans(matchingScan, scan); err != nil { + return scan, err + } + if scan.CellWeather != int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { + if scan.MustHaveRerolled(matchingScan) { + pokemon.setDittoAttributes("B0>00/[0N]", false, matchingScan, scan) + } else { + // set Ditto as it is most likely B0>00 if species did not reroll + pokemon.setDittoAttributes("B0>[00]/0N", true, matchingScan, scan) + } + scan.Confirmed = true + } else if matchingScan.Confirmed || scan.MustBeBoosted() { + pokemon.setDittoAttributes("BN>0P", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Confirmed = true + return unboostedScan, nil + // scan.MustBeUnboosted() need not be checked since matchingScan would not have been in B0 + } else { + // in case of BN>0P, we set Ditto to be a hidden 0P state, hoping we rediscover later + // setting 0P Ditto would also mean that we have a Ditto with unconfirmed IV which is a bad idea + if _, possible := dittoDisguises.Load(scan.Pokemon); possible { + if _, possible := dittoDisguises.Load(matchingScan.Pokemon); !possible { + // this guess is most likely to be correct except when Ditto pool just rerolled + pokemon.setDittoAttributes("BN>[0P] or B0>0N", true, matchingScan, scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return unboostedScan, nil + } + } + pokemon.setDittoAttributes("BN>0P or B0>[0N]", false, matchingScan, scan) + } + matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) + case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): + // we can never be sure if this is a Ditto or rerolling into non-Ditto + if scan.MustHaveRerolled(matchingScan) { + pokemon.setDittoAttributes("B0>PP/[PN]", false, matchingScan, scan) + } else { + pokemon.setDittoAttributes("B0>[PP]/PN", true, matchingScan, scan) + } + matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) + default: + pokemon.setDittoAttributes("B0>BN", false, matchingScan, scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) + } + return scan, nil + case -5: + switch scan.Weather { + case int32(pogo.GameplayWeatherProto_NONE): + // we can never be sure if this is a Ditto or rerolling into non-Ditto + if scan.MustHaveRerolled(matchingScan) { + pokemon.setDittoAttributes("0P>00/[0N]", false, matchingScan, scan) + } else { + pokemon.setDittoAttributes("0P>[00]/0N", true, matchingScan, scan) + } + matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return scan, nil + case int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY): + pokemon.setDittoAttributes("0P>PN", false, matchingScan, scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Confirmed = true + return scan, checkScans(matchingScan, scan) + } + if matchingScan.Weather != int32(pogo.GameplayWeatherProto_NONE) { + pokemon.setDittoAttributes("BN/PP/PN>B0", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + return scan, nil + } + if err := checkScans(matchingScan, scan); err != nil { + return scan, err + } + if scan.MustBeBoosted() { + pokemon.setDittoAttributes("0P>BN", false, matchingScan, scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Confirmed = true + } else if matchingScan.Confirmed || // this covers scan.MustBeUnboosted() + matchingScan.CellWeather != int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) { + pokemon.setDittoAttributes("00/0N>B0", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + scan.Confirmed = true + } else { + // same rationale as BN>0P or B0>[0N] + if _, possible := dittoDisguises.Load(scan.Pokemon); possible { + if _, possible := dittoDisguises.Load(matchingScan.Pokemon); !possible { + // this guess is most likely to be correct except when Ditto pool just rerolled + pokemon.setDittoAttributes("0N>[B0] or 0P>BN", true, matchingScan, scan) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + return scan, nil + } + } + pokemon.setDittoAttributes("0N>B0 or 0P>[BN]", false, matchingScan, scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + } + return scan, nil + case 10: + pokemon.setDittoAttributes("B0>0P", true, matchingScan, scan) + confirmDitto(scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_NONE) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + return matchingScan, nil // unboostedScan is a wrong guess in this case + case -10: + pokemon.setDittoAttributes("0P>B0", true, matchingScan, scan) + confirmDitto(scan) + matchingScan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + return scan, nil + default: + return scan, errors.New(fmt.Sprintf("Unexpected level %s -> %s", matchingScan, scan)) + } + } + if isBoosted { + if scan.MustBeUnboosted() { + pokemon.setDittoAttributes("B0", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_NONE) + scan.Confirmed = true + return scan, checkScans(unboostedScan, scan) + } + scan.Confirmed = scan.MustBeBoosted() + return scan, checkScans(boostedScan, scan) + } else if scan.MustBeBoosted() { + pokemon.setDittoAttributes("0P", true, matchingScan, scan) + confirmDitto(scan) + scan.Weather = int32(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + scan.Confirmed = true + return unboostedScan, checkScans(boostedScan, scan) + } + scan.Confirmed = scan.MustBeUnboosted() + return scan, checkScans(unboostedScan, scan) +} + +func (pokemon *Pokemon) clearIv(cp bool) { + if pokemon.AtkIv.Valid || pokemon.DefIv.Valid || pokemon.StaIv.Valid || pokemon.Iv.Valid { + pokemon.dirty = true + } + pokemon.AtkIv = null.NewInt(0, false) + pokemon.DefIv = null.NewInt(0, false) + pokemon.StaIv = null.NewInt(0, false) + pokemon.Iv = null.NewFloat(0, false) + if cp { + switch pokemon.SeenType.ValueOrZero() { + case SeenType_LureEncounter: + pokemon.SetSeenType(null.StringFrom(SeenType_LureWild)) + case SeenType_Encounter: + pokemon.SetSeenType(null.StringFrom(SeenType_Wild)) + } + pokemon.SetCp(null.NewInt(0, false)) + pokemon.SetPvp(null.NewString("", false)) + } +} + +// caller should setPokemonDisplay prior to calling this +func (pokemon *Pokemon) addEncounterPokemon(ctx context.Context, db db.DbDetails, proto *pogo.PokemonProto, username string) { + pokemon.SetUsername(null.StringFrom(username)) + pokemon.SetShiny(null.BoolFrom(proto.PokemonDisplay.Shiny)) + pokemon.SetCp(null.IntFrom(int64(proto.Cp))) + pokemon.SetMove1(null.IntFrom(int64(proto.Move1))) + pokemon.SetMove2(null.IntFrom(int64(proto.Move2))) + pokemon.SetHeight(null.FloatFrom(float64(proto.HeightM))) + pokemon.SetSize(null.IntFrom(int64(proto.Size))) + pokemon.SetWeight(null.FloatFrom(float64(proto.WeightKg))) + + scan := grpc.PokemonScan{ + Weather: int32(pokemon.Weather.Int64), + Strong: pokemon.IsStrong.Bool, + Attack: proto.IndividualAttack, + Defense: proto.IndividualDefense, + Stamina: proto.IndividualStamina, + CellWeather: int32(pokemon.Weather.Int64), + Pokemon: int32(proto.PokemonId), + Costume: int32(proto.PokemonDisplay.Costume), + Gender: int32(proto.PokemonDisplay.Gender), + Form: int32(proto.PokemonDisplay.Form), + } + if scan.CellWeather == int32(pogo.GameplayWeatherProto_NONE) { + weather, unlock, err := peekWeatherRecord(weatherCellIdFromLatLon(pokemon.Lat, pokemon.Lon)) + if weather == nil || !weather.GameplayCondition.Valid { + log.Warnf("Failed to obtain weather for Pokemon %d: %s", pokemon.Id, err) + } else { + scan.CellWeather = int32(weather.GameplayCondition.Int64) + } + if unlock != nil { + unlock() + } + } + if proto.CpMultiplier < 0.734 { + scan.Level = int32((58.215688455154954*proto.CpMultiplier-2.7012478057856497)*proto.CpMultiplier + 1.3220677708486794) + } else if proto.CpMultiplier < .795 { + scan.Level = int32(171.34093607855277*proto.CpMultiplier - 94.95626666368578) + } else { + scan.Level = int32(199.99995231630976*proto.CpMultiplier - 117.55996066890287) + } + + caughtIv, err := pokemon.detectDitto(&scan) + if err != nil { + caughtIv = &scan + log.Errorf("[POKEMON] Unexpected %d: %s", pokemon.Id, err) + } + if caughtIv == nil { // this can only happen for a 0P Ditto + pokemon.SetLevel(null.IntFrom(int64(scan.Level - 5))) + pokemon.clearIv(false) + } else { + pokemon.SetLevel(null.IntFrom(int64(caughtIv.Level))) + pokemon.calculateIv(int64(caughtIv.Attack), int64(caughtIv.Defense), int64(caughtIv.Stamina)) + } + if err == nil { + newScans := make([]*grpc.PokemonScan, len(pokemon.internal.ScanHistory)+1) + entriesCount := 0 + for _, oldEntry := range pokemon.internal.ScanHistory { + if oldEntry.Strong != scan.Strong || !oldEntry.Strong && + oldEntry.Weather == int32(pogo.GameplayWeatherProto_NONE) != + (scan.Weather == int32(pogo.GameplayWeatherProto_NONE)) { + newScans[entriesCount] = oldEntry + entriesCount++ + } + } + newScans[entriesCount] = &scan + pokemon.internal.ScanHistory = newScans[:entriesCount+1] + } else { + // undo possible changes + scan.Confirmed = false + scan.Weather = int32(pokemon.Weather.Int64) + pokemon.internal.ScanHistory = make([]*grpc.PokemonScan, 1) + pokemon.internal.ScanHistory[0] = &scan + } +} + +func (pokemon *Pokemon) updatePokemonFromEncounterProto(ctx context.Context, db db.DbDetails, encounterData *pogo.EncounterOutProto, username string, timestampMs int64) { + pokemon.SetIsEvent(0) + pokemon.addWildPokemon(ctx, db, encounterData.Pokemon, timestampMs, false) + // tappable encounter can also be available in seen as normal encounter once tapped + if pokemon.isSeenFromTappable() { + pokemon.SetSeenType(null.StringFrom(SeenType_Encounter)) + } + pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon.Pokemon, username) + + if pokemon.CellId.Valid == false { + centerCoord := s2.LatLngFromDegrees(pokemon.Lat, pokemon.Lon) + cellID := s2.CellIDFromLatLng(centerCoord).Parent(15) + pokemon.SetCellId(null.IntFrom(int64(cellID))) + } +} + +func (pokemon *Pokemon) isSeenFromTappable() bool { + return pokemon.SeenType.ValueOrZero() != SeenType_TappableEncounter && pokemon.SeenType.ValueOrZero() != SeenType_TappableLureEncounter +} + +func (pokemon *Pokemon) updatePokemonFromDiskEncounterProto(ctx context.Context, db db.DbDetails, encounterData *pogo.DiskEncounterOutProto, username string) { + pokemon.SetIsEvent(0) + pokemon.setPokemonDisplay(int16(encounterData.Pokemon.PokemonId), encounterData.Pokemon.PokemonDisplay) + pokemon.SetSeenType(null.StringFrom(SeenType_LureEncounter)) + pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon, username) +} + +func (pokemon *Pokemon) updatePokemonFromTappableEncounterProto(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, encounterData *pogo.TappableEncounterProto, username string, timestampMs int64) { + pokemon.SetIsEvent(0) + pokemon.SetLat(request.LocationHintLat) + pokemon.SetLon(request.LocationHintLng) + + if spawnpointId := request.GetLocation().GetSpawnpointId(); spawnpointId != "" { + pokemon.SetSeenType(null.StringFrom(SeenType_TappableEncounter)) + + spawnId, err := strconv.ParseInt(spawnpointId, 16, 64) + if err != nil { + panic(err) + } + + pokemon.SetSpawnId(null.IntFrom(spawnId)) + pokemon.setExpireTimestampFromSpawnpoint(ctx, db, timestampMs, false) + } else if fortId := request.GetLocation().GetFortId(); fortId != "" { + pokemon.SetSeenType(null.StringFrom(SeenType_TappableLureEncounter)) + + pokemon.SetPokestopId(null.StringFrom(fortId)) + // we don't know any despawn times from lured/fort tappables + pokemon.SetExpireTimestamp(null.IntFrom(int64(timestampMs)/1000 + int64(120))) + pokemon.SetExpireTimestampVerified(false) + } + if !pokemon.Username.Valid { + pokemon.SetUsername(null.StringFrom(username)) + } + pokemon.setPokemonDisplay(int16(encounterData.Pokemon.PokemonId), encounterData.Pokemon.PokemonDisplay) + pokemon.addEncounterPokemon(ctx, db, encounterData.Pokemon, username) +} + +func (pokemon *Pokemon) setPokemonDisplay(pokemonId int16, display *pogo.PokemonDisplayProto) { + if !pokemon.isNewRecord() { + // If we would like to support detect A/B spawn in the future, fill in more code here from Chuck + var oldId int16 + if pokemon.IsDitto { + oldId = int16(pokemon.DisplayPokemonId.ValueOrZero()) + } else { + oldId = pokemon.PokemonId + } + if oldId != pokemonId || pokemon.Form != null.IntFrom(int64(display.Form)) || + pokemon.Costume != null.IntFrom(int64(display.Costume)) || + pokemon.Gender != null.IntFrom(int64(display.Gender)) || + pokemon.IsStrong.ValueOrZero() != display.IsStrongPokemon { + log.Debugf("Pokemon %d changed from (%d,%d,%d,%d,%t) to (%d,%d,%d,%d,%t)", pokemon.Id, oldId, + pokemon.Form.ValueOrZero(), pokemon.Costume.ValueOrZero(), pokemon.Gender.ValueOrZero(), + pokemon.IsStrong.ValueOrZero(), + pokemonId, display.Form, display.Costume, display.Gender, display.IsStrongPokemon) + pokemon.SetWeight(null.NewFloat(0, false)) + pokemon.SetHeight(null.NewFloat(0, false)) + pokemon.SetSize(null.NewInt(0, false)) + pokemon.SetMove1(null.NewInt(0, false)) + pokemon.SetMove2(null.NewInt(0, false)) + pokemon.SetCp(null.NewInt(0, false)) + pokemon.SetShiny(null.NewBool(false, false)) + pokemon.SetIsDitto(false) + pokemon.SetDisplayPokemonId(null.NewInt(0, false)) + pokemon.SetPvp(null.NewString("", false)) + } + } + if pokemon.isNewRecord() || !pokemon.IsDitto { + pokemon.SetPokemonId(pokemonId) + } + pokemon.SetGender(null.IntFrom(int64(display.Gender))) + pokemon.SetForm(null.IntFrom(int64(display.Form))) + pokemon.SetCostume(null.IntFrom(int64(display.Costume))) + if !pokemon.isNewRecord() { + pokemon.repopulateIv(int64(display.WeatherBoostedCondition), display.IsStrongPokemon) + } + pokemon.SetWeather(null.IntFrom(int64(display.WeatherBoostedCondition))) + pokemon.SetIsStrong(null.BoolFrom(display.IsStrongPokemon)) +} + +func (pokemon *Pokemon) repopulateIv(weather int64, isStrong bool) { + var isBoosted bool + if !pokemon.IsDitto { + isBoosted = weather != int64(pogo.GameplayWeatherProto_NONE) + if isStrong == pokemon.IsStrong.ValueOrZero() && + pokemon.Weather.ValueOrZero() != int64(pogo.GameplayWeatherProto_NONE) == isBoosted { + return + } + } else if isStrong { + log.Errorf("Strong Ditto??? I can't handle this fml %d", pokemon.Id) + pokemon.clearIv(true) + return + } else { + isBoosted = weather == int64(pogo.GameplayWeatherProto_PARTLY_CLOUDY) + // both Ditto and disguise are boosted and Ditto was not boosted: none -> boosted + // or both Ditto and disguise were boosted and Ditto is not boosted: boosted -> none + if pokemon.Weather.ValueOrZero() == int64(pogo.GameplayWeatherProto_PARTLY_CLOUDY) == isBoosted { + return + } + } + matchingScan, isBoostedMatches := pokemon.locateScan(isStrong, isBoosted) + var oldAtk, oldDef, oldSta int64 + if matchingScan == nil { + pokemon.SetLevel(null.NewInt(0, false)) + pokemon.clearIv(true) + } else { + oldLevel := pokemon.Level.ValueOrZero() + if pokemon.AtkIv.Valid { + oldAtk = pokemon.AtkIv.Int64 + oldDef = pokemon.DefIv.Int64 + oldSta = pokemon.StaIv.Int64 + } else { + oldAtk = -1 + oldDef = -1 + oldSta = -1 + } + newLevel := int64(matchingScan.Level) + if isBoostedMatches || isStrong { // strong Pokemon IV is unaffected by weather + pokemon.calculateIv(int64(matchingScan.Attack), int64(matchingScan.Defense), int64(matchingScan.Stamina)) + switch pokemon.SeenType.ValueOrZero() { + case SeenType_LureWild: + pokemon.SetSeenType(null.StringFrom(SeenType_LureEncounter)) + case SeenType_Wild: + pokemon.SetSeenType(null.StringFrom(SeenType_Encounter)) + } + } else { + pokemon.clearIv(true) + } + if !isBoostedMatches { + if isBoosted { + newLevel += 5 + } else { + newLevel -= 5 + } + } + pokemon.SetLevel(null.IntFrom(newLevel)) + if newLevel != oldLevel || pokemon.AtkIv.Valid && + (pokemon.AtkIv.Int64 != oldAtk || pokemon.DefIv.Int64 != oldDef || pokemon.StaIv.Int64 != oldSta) { + pokemon.SetCp(null.NewInt(0, false)) + pokemon.SetPvp(null.NewString("", false)) + } + } +} + +func (pokemon *Pokemon) recomputeCpIfNeeded(ctx context.Context, db db.DbDetails, weather map[int64]pogo.GameplayWeatherProto_WeatherCondition) { + if pokemon.Cp.Valid || ohbem == nil { + return + } + var displayPokemon int + shouldOverrideIv := false + var overrideIv *grpc.PokemonScan + if pokemon.IsDitto { + displayPokemon = int(pokemon.DisplayPokemonId.Int64) + if pokemon.Weather.Int64 == int64(pogo.GameplayWeatherProto_NONE) { + cellId := weatherCellIdFromLatLon(pokemon.Lat, pokemon.Lon) + cellWeather, found := weather[cellId] + if !found { + record, unlock, err := getWeatherRecordReadOnly(ctx, db, cellId) + if err != nil || record == nil || !record.GameplayCondition.Valid { + log.Warnf("[POKEMON] Failed to obtain weather for Pokemon %d: %s", pokemon.Id, err) + } else { + log.Warnf("[POKEMON] Weather not found locally for %d at %d", pokemon.Id, cellId) + cellWeather = pogo.GameplayWeatherProto_WeatherCondition(record.GameplayCondition.Int64) + found = true + } + if unlock != nil { + unlock() + } + } + if found && cellWeather == pogo.GameplayWeatherProto_PARTLY_CLOUDY { + shouldOverrideIv = true + scan, isBoostedMatches := pokemon.locateScan(false, false) + if scan != nil && isBoostedMatches { + overrideIv = scan + } + } + } + } else { + displayPokemon = int(pokemon.PokemonId) + } + var cp int + var err error + if shouldOverrideIv { + if overrideIv == nil { + return + } + // You should see boosted IV for 0P Ditto + cp, err = ohbem.CalculateCp(displayPokemon, int(pokemon.Form.ValueOrZero()), 0, + int(overrideIv.Attack), int(overrideIv.Defense), int(overrideIv.Stamina), float64(overrideIv.Level)) + } else { + if !pokemon.AtkIv.Valid || !pokemon.Level.Valid { + return + } + cp, err = ohbem.CalculateCp(displayPokemon, int(pokemon.Form.ValueOrZero()), 0, + int(pokemon.AtkIv.Int64), int(pokemon.DefIv.Int64), int(pokemon.StaIv.Int64), + float64(pokemon.Level.Int64)) + } + if err == nil { + pokemon.SetCp(null.IntFrom(int64(cp))) + } else { + log.Warnf("Pokemon %d %d CP unset due to error %s", pokemon.Id, displayPokemon, err) + } +} diff --git a/decoder/pokemon_process.go b/decoder/pokemon_process.go new file mode 100644 index 00000000..0d9585ad --- /dev/null +++ b/decoder/pokemon_process.go @@ -0,0 +1,92 @@ +package decoder + +import ( + "context" + "fmt" + "time" + + "golbat/db" + "golbat/pogo" + + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" +) + +func UpdatePokemonRecordWithEncounterProto(ctx context.Context, db db.DbDetails, encounter *pogo.EncounterOutProto, username string, timestamp int64) string { + if encounter.Pokemon == nil { + return "No encounter" + } + + encounterId := encounter.Pokemon.EncounterId + + // Remove from pending queue - encounter arrived so no need for delayed wild update + if pokemonPendingQueue != nil { + pokemonPendingQueue.Remove(encounterId) + } + + pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, encounterId) + if err != nil { + log.Errorf("Error pokemon [%d]: %s", encounterId, err) + return fmt.Sprintf("Error finding pokemon %s", err) + } + defer unlock() + + pokemon.updatePokemonFromEncounterProto(ctx, db, encounter, username, timestamp) + savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, timestamp/1000) + // updateEncounterStats() should only be called for encounters, and called + // even if we have the pokemon record already. + updateEncounterStats(pokemon) + + return fmt.Sprintf("%d %d Pokemon %d CP%d", encounter.Pokemon.EncounterId, encounterId, pokemon.PokemonId, encounter.Pokemon.Pokemon.Cp) +} + +func UpdatePokemonRecordWithDiskEncounterProto(ctx context.Context, db db.DbDetails, encounter *pogo.DiskEncounterOutProto, username string) string { + if encounter.Pokemon == nil { + return "No encounter" + } + + encounterId := uint64(encounter.Pokemon.PokemonDisplay.DisplayId) + + pokemon, unlock, err := getPokemonRecordForUpdate(ctx, db, encounterId) + if err != nil { + log.Errorf("Error pokemon [%d]: %s", encounterId, err) + return fmt.Sprintf("Error finding pokemon %s", err) + } + + if pokemon == nil || pokemon.isNewRecord() { + // No pokemon found - unlock not set when pokemon is nil + if unlock != nil { + unlock() + } + diskEncounterCache.Set(encounterId, encounter, ttlcache.DefaultTTL) + return fmt.Sprintf("%d Disk encounter without previous GMO - Pokemon stored for later", encounterId) + } + defer unlock() + + pokemon.updatePokemonFromDiskEncounterProto(ctx, db, encounter, username) + savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, time.Now().Unix()) + // updateEncounterStats() should only be called for encounters, and called + // even if we have the pokemon record already. + updateEncounterStats(pokemon) + + return fmt.Sprintf("%d Disk Pokemon %d CP%d", encounterId, pokemon.PokemonId, encounter.Pokemon.Cp) +} + +func UpdatePokemonRecordWithTappableEncounter(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, encounter *pogo.TappableEncounterProto, username string, timestampMs int64) string { + encounterId := request.GetEncounterId() + + pokemon, unlock, err := getOrCreatePokemonRecord(ctx, db, encounterId) + if err != nil { + log.Errorf("Error pokemon [%d]: %s", encounterId, err) + return fmt.Sprintf("Error finding pokemon %s", err) + } + defer unlock() + + pokemon.updatePokemonFromTappableEncounterProto(ctx, db, request, encounter, username, timestampMs) + savePokemonRecordAsAtTime(ctx, db, pokemon, true, true, true, time.Now().Unix()) + // updateEncounterStats() should only be called for encounters, and called + // even if we have the pokemon record already. + updateEncounterStats(pokemon) + + return fmt.Sprintf("%d Tappable Pokemon %d CP%d", encounterId, pokemon.PokemonId, encounter.Pokemon.Cp) +} diff --git a/decoder/pokemon_state.go b/decoder/pokemon_state.go new file mode 100644 index 00000000..dcfb2840 --- /dev/null +++ b/decoder/pokemon_state.go @@ -0,0 +1,490 @@ +package decoder + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + + "golbat/config" + "golbat/db" + "golbat/geo" + "golbat/webhooks" + + "github.com/UnownHash/gohbem" + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" +) + +// peekPokemonRecordReadOnly acquires lock, does NOT take snapshot. +// Use for read-only checks which will not cause a backing database lookup +// Caller must use returned unlock function +func peekPokemonRecordReadOnly(encounterId uint64) (*Pokemon, func(), error) { + if item := pokemonCache.Get(encounterId); item != nil { + pokemon := item.Value() + pokemon.Lock() + return pokemon, func() { pokemon.Unlock() }, nil + } + + return nil, nil, nil +} + +func loadPokemonFromDatabase(ctx context.Context, db db.DbDetails, encounterId uint64, pokemon *Pokemon) error { + err := db.PokemonDb.GetContext(ctx, pokemon, + "SELECT id, pokemon_id, lat, lon, spawn_id, expire_timestamp, atk_iv, def_iv, sta_iv, golbat_internal, iv, "+ + "move_1, move_2, gender, form, cp, level, strong, weather, costume, weight, height, size, "+ + "display_pokemon_id, is_ditto, pokestop_id, updated, first_seen_timestamp, changed, cell_id, "+ + "expire_timestamp_verified, shiny, username, pvp, is_event, seen_type "+ + "FROM pokemon WHERE id = ?", strconv.FormatUint(encounterId, 10)) + statsCollector.IncDbQuery("select pokemon", err) + + return err +} + +// getPokemonRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks, but will cause a backing database lookup +// Caller MUST call returned unlock function. +func getPokemonRecordReadOnly(ctx context.Context, db db.DbDetails, encounterId uint64) (*Pokemon, func(), error) { + // If we are in-memory only, this is identical to peek + if config.Config.PokemonMemoryOnly { + return peekPokemonRecordReadOnly(encounterId) + } + + // Check cache first + if item := pokemonCache.Get(encounterId); item != nil { + pokemon := item.Value() + pokemon.Lock() + return pokemon, func() { pokemon.Unlock() }, nil + } + + dbPokemon := Pokemon{} + err := loadPokemonFromDatabase(ctx, db, encounterId, &dbPokemon) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbPokemon.ClearDirty() + + // Atomically cache the loaded Pokemon - if another goroutine raced us, + // we'll get their Pokemon and use that instead (ensuring same mutex) + existingPokemon, _ := pokemonCache.GetOrSetFunc(encounterId, func() *Pokemon { + // Only called if key doesn't exist - our Pokemon wins + pokemonRtreeUpdatePokemonOnGet(&dbPokemon) + return &dbPokemon + }) + + pokemon := existingPokemon.Value() + pokemon.Lock() + return pokemon, func() { pokemon.Unlock() }, nil +} + +// getPokemonRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Use when modifying the Pokemon. +// Caller MUST call returned unlock function. +func getPokemonRecordForUpdate(ctx context.Context, db db.DbDetails, encounterId uint64) (*Pokemon, func(), error) { + pokemon, unlock, err := getPokemonRecordReadOnly(ctx, db, encounterId) + if err != nil || pokemon == nil { + return nil, nil, err + } + pokemon.snapshotOldValues() + return pokemon, unlock, nil +} + +// getOrCreatePokemonRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreatePokemonRecord(ctx context.Context, db db.DbDetails, encounterId uint64) (*Pokemon, func(), error) { + // Create new Pokemon atomically - function only called if key doesn't exist + pokemonItem, _ := pokemonCache.GetOrSetFunc(encounterId, func() *Pokemon { + return &Pokemon{Id: encounterId, newRecord: true} + }) + + pokemon := pokemonItem.Value() + pokemon.Lock() + + if config.Config.PokemonMemoryOnly { + pokemon.snapshotOldValues() + return pokemon, func() { pokemon.Unlock() }, nil + } + + if pokemon.newRecord { + // We should attempt to load from database + err := loadPokemonFromDatabase(ctx, db, encounterId, pokemon) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + pokemon.Unlock() + return nil, nil, err + } + } else { + // We loaded + pokemon.newRecord = false + pokemon.ClearDirty() + pokemonRtreeUpdatePokemonOnGet(pokemon) + } + } + + pokemon.snapshotOldValues() + return pokemon, func() { pokemon.Unlock() }, nil +} + +// hasChangesPokemon compares two Pokemon structs +// Ignored: Username, Iv, Pvp +// Float tolerance: Lat, Lon +// Null Float tolerance: Weight, Height, Capture1, Capture2, Capture3 +func hasChangesPokemon(old *Pokemon, new *Pokemon) bool { + return old.Id != new.Id || + old.PokestopId != new.PokestopId || + old.SpawnId != new.SpawnId || + old.Size != new.Size || + old.ExpireTimestamp != new.ExpireTimestamp || + old.Updated != new.Updated || + old.PokemonId != new.PokemonId || + old.Move1 != new.Move1 || + old.Move2 != new.Move2 || + old.Gender != new.Gender || + old.Cp != new.Cp || + old.AtkIv != new.AtkIv || + old.DefIv != new.DefIv || + old.StaIv != new.StaIv || + old.Form != new.Form || + old.Level != new.Level || + old.IsStrong != new.IsStrong || + old.Weather != new.Weather || + old.Costume != new.Costume || + old.FirstSeenTimestamp != new.FirstSeenTimestamp || + old.Changed != new.Changed || + old.CellId != new.CellId || + old.ExpireTimestampVerified != new.ExpireTimestampVerified || + old.DisplayPokemonId != new.DisplayPokemonId || + old.IsDitto != new.IsDitto || + old.SeenType != new.SeenType || + old.Shiny != new.Shiny || + old.IsEvent != new.IsEvent || + !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || + !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) || + !nullFloatAlmostEqual(old.Weight, new.Weight, floatTolerance) || + !nullFloatAlmostEqual(old.Height, new.Height, floatTolerance) || + !nullFloatAlmostEqual(old.Capture1, new.Capture1, floatTolerance) || + !nullFloatAlmostEqual(old.Capture2, new.Capture2, floatTolerance) || + !nullFloatAlmostEqual(old.Capture3, new.Capture3, floatTolerance) +} + +func savePokemonRecordAsAtTime(ctx context.Context, db db.DbDetails, pokemon *Pokemon, isEncounter, writeDB, webhook bool, now int64) { + if !pokemon.newRecord && !pokemon.IsDirty() { + return + } + + // uncomment to debug excessive writes + //if !pokemon.isNewRecord() && oldPokemon.AtkIv == pokemon.AtkIv && oldPokemon.DefIv == pokemon.DefIv && oldPokemon.StaIv == pokemon.StaIv && oldPokemon.Level == pokemon.Level && oldPokemon.ExpireTimestampVerified == pokemon.ExpireTimestampVerified && oldPokemon.PokemonId == pokemon.PokemonId && oldPokemon.ExpireTimestamp == pokemon.ExpireTimestamp && oldPokemon.PokestopId == pokemon.PokestopId && math.Abs(pokemon.Lat-oldPokemon.Lat) < .000001 && math.Abs(pokemon.Lon-oldPokemon.Lon) < .000001 { + // log.Errorf("Why are we updating this? %s", cmp.Diff(oldPokemon, pokemon, cmp.Options{ + // ignoreNearFloats, ignoreNearNullFloats, + // cmpopts.IgnoreFields(Pokemon{}, "Username", "Iv", "Pvp"), + // })) + //} + + if pokemon.FirstSeenTimestamp == 0 { + pokemon.FirstSeenTimestamp = now + } + + pokemon.SetUpdated(null.IntFrom(now)) + if pokemon.isNewRecord() || pokemon.oldValues.PokemonId != pokemon.PokemonId || pokemon.oldValues.Cp != pokemon.Cp { + pokemon.SetChanged(now) + } + + changePvpField := false + var pvpResults map[string][]gohbem.PokemonEntry + if ohbem != nil { + // Calculating PVP data - check for changes in pokemon properties that affect PVP rankings + // For new records, always calculate; for existing, check if relevant fields changed + shouldCalculatePvp := pokemon.AtkIv.Valid && (pokemon.isNewRecord() || pokemon.IsDirty()) + if shouldCalculatePvp { + pvp, err := ohbem.QueryPvPRank(int(pokemon.PokemonId), + int(pokemon.Form.ValueOrZero()), + int(pokemon.Costume.ValueOrZero()), + int(pokemon.Gender.ValueOrZero()), + int(pokemon.AtkIv.ValueOrZero()), + int(pokemon.DefIv.ValueOrZero()), + int(pokemon.StaIv.ValueOrZero()), + float64(pokemon.Level.ValueOrZero())) + + if err == nil { + pvpBytes, _ := json.Marshal(pvp) + pokemon.Pvp = null.StringFrom(string(pvpBytes)) + changePvpField = true + pvpResults = pvp + } + } + if !pokemon.AtkIv.Valid && pokemon.isNewRecord() { + pokemon.Pvp = null.NewString("", false) + changePvpField = true + } + } + + var oldSeenType string + if !pokemon.oldValues.SeenType.Valid { + oldSeenType = "n/a" + } else { + oldSeenType = pokemon.oldValues.SeenType.ValueOrZero() + } + + log.Debugf("Updating pokemon [%d] from %s->%s - newRecord: %t", pokemon.Id, oldSeenType, pokemon.SeenType.ValueOrZero(), pokemon.isNewRecord()) + //log.Println(cmp.Diff(oldPokemon, pokemon)) + + if writeDB && !config.Config.PokemonMemoryOnly { + if isEncounter && config.Config.PokemonInternalToDb { + unboosted, boosted, strong := pokemon.locateAllScans() + if unboosted != nil && boosted != nil { + unboosted.RemoveDittoAuxInfo() + boosted.RemoveDittoAuxInfo() + } + if strong != nil { + strong.RemoveDittoAuxInfo() + } + marshaled, err := proto.Marshal(&pokemon.internal) + if err == nil { + pokemon.GolbatInternal = marshaled + } else { + log.Errorf("[POKEMON] Failed to marshal internal data for %d, data may be lost: %s", pokemon.Id, err) + } + } + if pokemon.isNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Pokemon", strconv.FormatUint(pokemon.Id, 10), pokemon.changedFields) + } + pvpField, pvpValue := "", "" + if changePvpField { + pvpField, pvpValue = "pvp, ", ":pvp, " + } + res, err := db.PokemonDb.NamedExecContext(ctx, fmt.Sprintf("INSERT INTO pokemon (id, pokemon_id, lat, lon,"+ + "spawn_id, expire_timestamp, atk_iv, def_iv, sta_iv, golbat_internal, iv, move_1, move_2,"+ + "gender, form, cp, level, strong, weather, costume, weight, height, size,"+ + "display_pokemon_id, is_ditto, pokestop_id, updated, first_seen_timestamp, changed, cell_id,"+ + "expire_timestamp_verified, shiny, username, %s is_event, seen_type) "+ + "VALUES (\"%d\", :pokemon_id, :lat, :lon, :spawn_id, :expire_timestamp, :atk_iv, :def_iv, :sta_iv,"+ + ":golbat_internal, :iv, :move_1, :move_2, :gender, :form, :cp, :level, :strong, :weather, :costume,"+ + ":weight, :height, :size, :display_pokemon_id, :is_ditto, :pokestop_id, :updated,"+ + ":first_seen_timestamp, :changed, :cell_id, :expire_timestamp_verified, :shiny, :username, %s :is_event,"+ + ":seen_type)", pvpField, pokemon.Id, pvpValue), pokemon) + + statsCollector.IncDbQuery("insert pokemon", err) + if err != nil { + log.Errorf("insert pokemon: [%d] %s", pokemon.Id, err) + log.Errorf("Full structure: %+v", pokemon) + pokemonCache.Delete(pokemon.Id) + // Force reload of pokemon from database + return + } + + rows, rowsErr := res.RowsAffected() + log.Debugf("Inserting pokemon [%d] after insert res = %d %v", pokemon.Id, rows, rowsErr) + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Pokemon", strconv.FormatUint(pokemon.Id, 10), pokemon.changedFields) + } + pvpUpdate := "" + if changePvpField { + pvpUpdate = "pvp = :pvp, " + } + res, err := db.PokemonDb.NamedExecContext(ctx, fmt.Sprintf("UPDATE pokemon SET "+ + "pokestop_id = :pokestop_id, "+ + "spawn_id = :spawn_id, "+ + "lat = :lat, "+ + "lon = :lon, "+ + "weight = :weight, "+ + "height = :height, "+ + "size = :size, "+ + "expire_timestamp = :expire_timestamp, "+ + "updated = :updated, "+ + "pokemon_id = :pokemon_id, "+ + "move_1 = :move_1, "+ + "move_2 = :move_2, "+ + "gender = :gender, "+ + "cp = :cp, "+ + "atk_iv = :atk_iv, "+ + "def_iv = :def_iv, "+ + "sta_iv = :sta_iv, "+ + "golbat_internal = :golbat_internal,"+ + "iv = :iv,"+ + "form = :form, "+ + "level = :level, "+ + "strong = :strong, "+ + "weather = :weather, "+ + "costume = :costume, "+ + "first_seen_timestamp = :first_seen_timestamp, "+ + "changed = :changed, "+ + "cell_id = :cell_id, "+ + "expire_timestamp_verified = :expire_timestamp_verified, "+ + "display_pokemon_id = :display_pokemon_id, "+ + "is_ditto = :is_ditto, "+ + "seen_type = :seen_type, "+ + "shiny = :shiny, "+ + "username = :username, "+ + "%s"+ + "is_event = :is_event "+ + "WHERE id = \"%d\"", pvpUpdate, pokemon.Id), pokemon, + ) + statsCollector.IncDbQuery("update pokemon", err) + if err != nil { + log.Errorf("Update pokemon [%d] %s", pokemon.Id, err) + log.Errorf("Full structure: %+v", pokemon) + pokemonCache.Delete(pokemon.Id) + // Force reload of pokemon from database + + return + } + rows, rowsErr := res.RowsAffected() + log.Debugf("Updating pokemon [%d] after update res = %d %v", pokemon.Id, rows, rowsErr) + } + } else { + if dbDebugEnabled { + dbDebugLog("MEMORY", "Pokemon", strconv.FormatUint(pokemon.Id, 10), pokemon.changedFields) + } + } + + // Update pokemon rtree + if pokemon.isNewRecord() { + addPokemonToTree(pokemon) + } else if pokemon.Lat != pokemon.oldValues.Lat || pokemon.Lon != pokemon.oldValues.Lon { + // Position changed - update R-tree by removing from old position and adding to new + removePokemonFromTree(pokemon.Id, pokemon.oldValues.Lat, pokemon.oldValues.Lon) + addPokemonToTree(pokemon) + } + + updatePokemonLookup(pokemon, changePvpField, pvpResults) + + areas := MatchStatsGeofence(pokemon.Lat, pokemon.Lon) + if webhook { + createPokemonWebhooks(ctx, db, pokemon, areas) + } + updatePokemonStats(pokemon, areas, now) + + if dbDebugEnabled { + pokemon.changedFields = pokemon.changedFields[:0] + } + pokemon.newRecord = false // After saving, it's no longer a new record + pokemon.ClearDirty() + + pokemon.Pvp = null.NewString("", false) // Reset PVP field to avoid keeping it in memory cache + + if db.UsePokemonCache { + pokemonCache.Set(pokemon.Id, pokemon, pokemon.remainingDuration(now)) + } +} + +type PokemonWebhook struct { + SpawnpointId string `json:"spawnpoint_id"` + PokestopId string `json:"pokestop_id"` + PokestopName *string `json:"pokestop_name"` + EncounterId string `json:"encounter_id"` + PokemonId int16 `json:"pokemon_id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + DisappearTime int64 `json:"disappear_time"` + DisappearTimeVerified bool `json:"disappear_time_verified"` + FirstSeen int64 `json:"first_seen"` + LastModifiedTime null.Int `json:"last_modified_time"` + Gender null.Int `json:"gender"` + Cp null.Int `json:"cp"` + Form null.Int `json:"form"` + Costume null.Int `json:"costume"` + IndividualAttack null.Int `json:"individual_attack"` + IndividualDefense null.Int `json:"individual_defense"` + IndividualStamina null.Int `json:"individual_stamina"` + PokemonLevel null.Int `json:"pokemon_level"` + Move1 null.Int `json:"move_1"` + Move2 null.Int `json:"move_2"` + Weight null.Float `json:"weight"` + Size null.Int `json:"size"` + Height null.Float `json:"height"` + Weather null.Int `json:"weather"` + Capture1 float64 `json:"capture_1"` + Capture2 float64 `json:"capture_2"` + Capture3 float64 `json:"capture_3"` + Shiny null.Bool `json:"shiny"` + Username null.String `json:"username"` + DisplayPokemonId null.Int `json:"display_pokemon_id"` + IsEvent int8 `json:"is_event"` + SeenType null.String `json:"seen_type"` + Pvp json.RawMessage `json:"pvp"` +} + +func createPokemonWebhooks(ctx context.Context, db db.DbDetails, pokemon *Pokemon, areas []geo.AreaName) { + if pokemon.isNewRecord() || + pokemon.oldValues.PokemonId != pokemon.PokemonId || + pokemon.oldValues.Weather != pokemon.Weather || + pokemon.oldValues.Cp != pokemon.Cp { + + spawnpointId := "None" + if pokemon.SpawnId.Valid { + spawnpointId = strconv.FormatInt(pokemon.SpawnId.ValueOrZero(), 16) + } + + pokestopId := "None" + if pokemon.PokestopId.Valid { + pokestopId = pokemon.PokestopId.ValueOrZero() + } + + var pokestopName *string + if pokemon.PokestopId.Valid { + pokestop, unlock, _ := getPokestopRecordReadOnly(ctx, db, pokemon.PokestopId.String) + name := "Unknown" + if pokestop != nil { + name = pokestop.Name.ValueOrZero() + unlock() + } + pokestopName = &name + } + + var pvp json.RawMessage + if pokemon.Pvp.Valid { + pvp = json.RawMessage(pokemon.Pvp.ValueOrZero()) + } + + pokemonHook := PokemonWebhook{ + SpawnpointId: spawnpointId, + PokestopId: pokestopId, + PokestopName: pokestopName, + EncounterId: strconv.FormatUint(pokemon.Id, 10), + PokemonId: pokemon.PokemonId, + Latitude: pokemon.Lat, + Longitude: pokemon.Lon, + DisappearTime: pokemon.ExpireTimestamp.ValueOrZero(), + DisappearTimeVerified: pokemon.ExpireTimestampVerified, + FirstSeen: pokemon.FirstSeenTimestamp, + LastModifiedTime: pokemon.Updated, + Gender: pokemon.Gender, + Cp: pokemon.Cp, + Form: pokemon.Form, + Costume: pokemon.Costume, + IndividualAttack: pokemon.AtkIv, + IndividualDefense: pokemon.DefIv, + IndividualStamina: pokemon.StaIv, + PokemonLevel: pokemon.Level, + Move1: pokemon.Move1, + Move2: pokemon.Move2, + Weight: pokemon.Weight, + Size: pokemon.Size, + Height: pokemon.Height, + Weather: pokemon.Weather, + Capture1: pokemon.Capture1.ValueOrZero(), + Capture2: pokemon.Capture2.ValueOrZero(), + Capture3: pokemon.Capture3.ValueOrZero(), + Shiny: pokemon.Shiny, + Username: pokemon.Username, + DisplayPokemonId: pokemon.DisplayPokemonId, + IsEvent: pokemon.IsEvent, + SeenType: pokemon.SeenType, + Pvp: pvp, + } + + if pokemon.AtkIv.Valid && pokemon.DefIv.Valid && pokemon.StaIv.Valid { + webhooksSender.AddMessage(webhooks.PokemonIV, pokemonHook, areas) + } else { + webhooksSender.AddMessage(webhooks.PokemonNoIV, pokemonHook, areas) + } + } +} diff --git a/decoder/pokestop.go b/decoder/pokestop.go index ee7e966c..20d771b7 100644 --- a/decoder/pokestop.go +++ b/decoder/pokestop.go @@ -1,1043 +1,589 @@ package decoder import ( - "context" - "database/sql" - "encoding/json" - "errors" "fmt" - "strings" - "time" + "sync" - "github.com/jellydator/ttlcache/v3" - "github.com/paulmach/orb/geojson" - log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" - - "golbat/config" - "golbat/db" - "golbat/pogo" - "golbat/tz" - "golbat/util" - "golbat/webhooks" + "github.com/guregu/null/v6" ) // Pokestop struct. -// REMINDER! Keep hasChangesPokestop updated after making changes type Pokestop struct { - Id string `db:"id" json:"id"` - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - Name null.String `db:"name" json:"name"` - Url null.String `db:"url" json:"url"` - LureExpireTimestamp null.Int `db:"lure_expire_timestamp" json:"lure_expire_timestamp"` - LastModifiedTimestamp null.Int `db:"last_modified_timestamp" json:"last_modified_timestamp"` - Updated int64 `db:"updated" json:"updated"` - Enabled null.Bool `db:"enabled" json:"enabled"` - QuestType null.Int `db:"quest_type" json:"quest_type"` - QuestTimestamp null.Int `db:"quest_timestamp" json:"quest_timestamp"` - QuestTarget null.Int `db:"quest_target" json:"quest_target"` - QuestConditions null.String `db:"quest_conditions" json:"quest_conditions"` - QuestRewards null.String `db:"quest_rewards" json:"quest_rewards"` - QuestTemplate null.String `db:"quest_template" json:"quest_template"` - QuestTitle null.String `db:"quest_title" json:"quest_title"` - QuestExpiry null.Int `db:"quest_expiry" json:"quest_expiry"` - CellId null.Int `db:"cell_id" json:"cell_id"` - Deleted bool `db:"deleted" json:"deleted"` - LureId int16 `db:"lure_id" json:"lure_id"` - FirstSeenTimestamp int16 `db:"first_seen_timestamp" json:"first_seen_timestamp"` - SponsorId null.Int `db:"sponsor_id" json:"sponsor_id"` - PartnerId null.String `db:"partner_id" json:"partner_id"` - ArScanEligible null.Int `db:"ar_scan_eligible" json:"ar_scan_eligible"` // is an 8 - PowerUpLevel null.Int `db:"power_up_level" json:"power_up_level"` - PowerUpPoints null.Int `db:"power_up_points" json:"power_up_points"` - PowerUpEndTimestamp null.Int `db:"power_up_end_timestamp" json:"power_up_end_timestamp"` - AlternativeQuestType null.Int `db:"alternative_quest_type" json:"alternative_quest_type"` - AlternativeQuestTimestamp null.Int `db:"alternative_quest_timestamp" json:"alternative_quest_timestamp"` - AlternativeQuestTarget null.Int `db:"alternative_quest_target" json:"alternative_quest_target"` - AlternativeQuestConditions null.String `db:"alternative_quest_conditions" json:"alternative_quest_conditions"` - AlternativeQuestRewards null.String `db:"alternative_quest_rewards" json:"alternative_quest_rewards"` - AlternativeQuestTemplate null.String `db:"alternative_quest_template" json:"alternative_quest_template"` - AlternativeQuestTitle null.String `db:"alternative_quest_title" json:"alternative_quest_title"` - AlternativeQuestExpiry null.Int `db:"alternative_quest_expiry" json:"alternative_quest_expiry"` - Description null.String `db:"description" json:"description"` - ShowcaseFocus null.String `db:"showcase_focus" json:"showcase_focus"` - ShowcasePokemon null.Int `db:"showcase_pokemon_id" json:"showcase_pokemon_id"` - ShowcasePokemonForm null.Int `db:"showcase_pokemon_form_id" json:"showcase_pokemon_form_id"` - ShowcasePokemonType null.Int `db:"showcase_pokemon_type_id" json:"showcase_pokemon_type_id"` - ShowcaseRankingStandard null.Int `db:"showcase_ranking_standard" json:"showcase_ranking_standard"` - ShowcaseExpiry null.Int `db:"showcase_expiry" json:"showcase_expiry"` - ShowcaseRankings null.String `db:"showcase_rankings" json:"showcase_rankings"` - //`id` varchar(35) NOT NULL, - //`lat` double(18,14) NOT NULL, - //`lon` double(18,14) NOT NULL, - //`name` varchar(128) DEFAULT NULL, - //`url` varchar(200) DEFAULT NULL, - //`lure_expire_timestamp` int unsigned DEFAULT NULL, - //`last_modified_timestamp` int unsigned DEFAULT NULL, - //`updated` int unsigned NOT NULL, - //`enabled` tinyint unsigned DEFAULT NULL, - //`quest_type` int unsigned DEFAULT NULL, - //`quest_timestamp` int unsigned DEFAULT NULL, - //`quest_target` smallint unsigned DEFAULT NULL, - //`quest_conditions` text, - //`quest_rewards` text, - //`quest_template` varchar(100) DEFAULT NULL, - //`quest_title` varchar(100) DEFAULT NULL, - //`quest_reward_type` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].type'),_utf8mb4'$[0]')) VIRTUAL, - //`quest_item_id` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.item_id'),_utf8mb4'$[0]')) VIRTUAL, - //`quest_reward_amount` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.amount'),_utf8mb4'$[0]')) VIRTUAL, - //`cell_id` bigint unsigned DEFAULT NULL, - //`deleted` tinyint unsigned NOT NULL DEFAULT '0', - //`lure_id` smallint DEFAULT '0', - //`first_seen_timestamp` int unsigned NOT NULL, - //`sponsor_id` smallint unsigned DEFAULT NULL, - //`partner_id` varchar(35) DEFAULT NULL, - //`quest_pokemon_id` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.pokemon_id'),_utf8mb4'$[0]')) VIRTUAL, - //`ar_scan_eligible` tinyint unsigned DEFAULT NULL, - //`power_up_level` smallint unsigned DEFAULT NULL, - //`power_up_points` int unsigned DEFAULT NULL, - //`power_up_end_timestamp` int unsigned DEFAULT NULL, - //`alternative_quest_type` int unsigned DEFAULT NULL, - //`alternative_quest_timestamp` int unsigned DEFAULT NULL, - //`alternative_quest_target` smallint unsigned DEFAULT NULL, - //`alternative_quest_conditions` text, - //`alternative_quest_rewards` text, - //`alternative_quest_template` varchar(100) DEFAULT NULL, - //`alternative_quest_title` varchar(100) DEFAULT NULL, - + mu sync.Mutex `db:"-"` // Object-level mutex + + Id string `db:"id"` + Lat float64 `db:"lat"` + Lon float64 `db:"lon"` + Name null.String `db:"name"` + Url null.String `db:"url"` + LureExpireTimestamp null.Int `db:"lure_expire_timestamp"` + LastModifiedTimestamp null.Int `db:"last_modified_timestamp"` + Updated int64 `db:"updated"` + Enabled null.Bool `db:"enabled"` + QuestType null.Int `db:"quest_type"` + QuestTimestamp null.Int `db:"quest_timestamp"` + QuestTarget null.Int `db:"quest_target"` + QuestConditions null.String `db:"quest_conditions"` + QuestRewards null.String `db:"quest_rewards"` + QuestTemplate null.String `db:"quest_template"` + QuestTitle null.String `db:"quest_title"` + QuestExpiry null.Int `db:"quest_expiry"` + CellId null.Int `db:"cell_id"` + Deleted bool `db:"deleted"` + LureId int16 `db:"lure_id"` + FirstSeenTimestamp int16 `db:"first_seen_timestamp"` + SponsorId null.Int `db:"sponsor_id"` + PartnerId null.String `db:"partner_id"` + ArScanEligible null.Int `db:"ar_scan_eligible"` // is an 8 + PowerUpLevel null.Int `db:"power_up_level"` + PowerUpPoints null.Int `db:"power_up_points"` + PowerUpEndTimestamp null.Int `db:"power_up_end_timestamp"` + AlternativeQuestType null.Int `db:"alternative_quest_type"` + AlternativeQuestTimestamp null.Int `db:"alternative_quest_timestamp"` + AlternativeQuestTarget null.Int `db:"alternative_quest_target"` + AlternativeQuestConditions null.String `db:"alternative_quest_conditions"` + AlternativeQuestRewards null.String `db:"alternative_quest_rewards"` + AlternativeQuestTemplate null.String `db:"alternative_quest_template"` + AlternativeQuestTitle null.String `db:"alternative_quest_title"` + AlternativeQuestExpiry null.Int `db:"alternative_quest_expiry"` + Description null.String `db:"description"` + ShowcaseFocus null.String `db:"showcase_focus"` + ShowcasePokemon null.Int `db:"showcase_pokemon_id"` + ShowcasePokemonForm null.Int `db:"showcase_pokemon_form_id"` + ShowcasePokemonType null.Int `db:"showcase_pokemon_type_id"` + ShowcaseRankingStandard null.Int `db:"showcase_ranking_standard"` + ShowcaseExpiry null.Int `db:"showcase_expiry"` + ShowcaseRankings null.String `db:"showcase_rankings"` + + dirty bool `db:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues PokestopOldValues `db:"-"` // Old values for webhook comparison } -func GetPokestopRecord(ctx context.Context, db db.DbDetails, fortId string) (*Pokestop, error) { - stop := pokestopCache.Get(fortId) - if stop != nil { - pokestop := stop.Value() - //log.Debugf("GetPokestopRecord %s (from cache)", fortId) - return &pokestop, nil - } - pokestop := Pokestop{} - err := db.GeneralDb.GetContext(ctx, &pokestop, - `SELECT pokestop.id, lat, lon, name, url, enabled, lure_expire_timestamp, last_modified_timestamp, - pokestop.updated, quest_type, quest_timestamp, quest_target, quest_conditions, - quest_rewards, quest_template, quest_title, - alternative_quest_type, alternative_quest_timestamp, alternative_quest_target, - alternative_quest_conditions, alternative_quest_rewards, - alternative_quest_template, alternative_quest_title, cell_id, deleted, lure_id, sponsor_id, partner_id, - ar_scan_eligible, power_up_points, power_up_level, power_up_end_timestamp, - quest_expiry, alternative_quest_expiry, description, showcase_pokemon_id, showcase_pokemon_form_id, - showcase_pokemon_type_id, showcase_ranking_standard, showcase_expiry, showcase_rankings - FROM pokestop - WHERE pokestop.id = ? `, fortId) - //log.Debugf("GetPokestopRecord %s (from db)", fortId) +// PokestopOldValues holds old field values for webhook comparison (populated when loading from cache/DB) +type PokestopOldValues struct { + QuestType null.Int + AlternativeQuestType null.Int + LureExpireTimestamp null.Int + LureId int16 + PowerUpEndTimestamp null.Int + Name null.String + Url null.String + Description null.String + Lat float64 + Lon float64 +} - statsCollector.IncDbQuery("select pokestop", err) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } +//`id` varchar(35) NOT NULL, +//`lat` double(18,14) NOT NULL, +//`lon` double(18,14) NOT NULL, +//`name` varchar(128) DEFAULT NULL, +//`url` varchar(200) DEFAULT NULL, +//`lure_expire_timestamp` int unsigned DEFAULT NULL, +//`last_modified_timestamp` int unsigned DEFAULT NULL, +//`updated` int unsigned NOT NULL, +//`enabled` tinyint unsigned DEFAULT NULL, +//`quest_type` int unsigned DEFAULT NULL, +//`quest_timestamp` int unsigned DEFAULT NULL, +//`quest_target` smallint unsigned DEFAULT NULL, +//`quest_conditions` text, +//`quest_rewards` text, +//`quest_template` varchar(100) DEFAULT NULL, +//`quest_title` varchar(100) DEFAULT NULL, +//`quest_reward_type` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].type'),_utf8mb4'$[0]')) VIRTUAL, +//`quest_item_id` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.item_id'),_utf8mb4'$[0]')) VIRTUAL, +//`quest_reward_amount` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.amount'),_utf8mb4'$[0]')) VIRTUAL, +//`cell_id` bigint unsigned DEFAULT NULL, +//`deleted` tinyint unsigned NOT NULL DEFAULT '0', +//`lure_id` smallint DEFAULT '0', +//`first_seen_timestamp` int unsigned NOT NULL, +//`sponsor_id` smallint unsigned DEFAULT NULL, +//`partner_id` varchar(35) DEFAULT NULL, +//`quest_pokemon_id` smallint unsigned GENERATED ALWAYS AS (json_extract(json_extract(`quest_rewards`,_utf8mb4'$[*].info.pokemon_id'),_utf8mb4'$[0]')) VIRTUAL, +//`ar_scan_eligible` tinyint unsigned DEFAULT NULL, +//`power_up_level` smallint unsigned DEFAULT NULL, +//`power_up_points` int unsigned DEFAULT NULL, +//`power_up_end_timestamp` int unsigned DEFAULT NULL, +//`alternative_quest_type` int unsigned DEFAULT NULL, +//`alternative_quest_timestamp` int unsigned DEFAULT NULL, +//`alternative_quest_target` smallint unsigned DEFAULT NULL, +//`alternative_quest_conditions` text, +//`alternative_quest_rewards` text, +//`alternative_quest_template` varchar(100) DEFAULT NULL, +//`alternative_quest_title` varchar(100) DEFAULT NULL, + +// IsDirty returns true if any field has been modified +func (p *Pokestop) IsDirty() bool { + return p.dirty +} - pokestopCache.Set(fortId, pokestop, ttlcache.DefaultTTL) - if config.Config.TestFortInMemory { - fortRtreeUpdatePokestopOnGet(&pokestop) - } - return &pokestop, nil +// ClearDirty resets the dirty flag (call after saving to DB) +func (p *Pokestop) ClearDirty() { + p.dirty = false } -// hasChangesPokestop compares two Pokestop structs -// Float tolerance: Lat, Lon -func hasChangesPokestop(old *Pokestop, new *Pokestop) bool { - return old.Id != new.Id || - old.Name != new.Name || - old.Url != new.Url || - old.LureExpireTimestamp != new.LureExpireTimestamp || - old.LastModifiedTimestamp != new.LastModifiedTimestamp || - old.Updated != new.Updated || - old.Enabled != new.Enabled || - old.QuestType != new.QuestType || - old.QuestTimestamp != new.QuestTimestamp || - old.QuestTarget != new.QuestTarget || - old.QuestConditions != new.QuestConditions || - old.QuestRewards != new.QuestRewards || - old.QuestTemplate != new.QuestTemplate || - old.QuestTitle != new.QuestTitle || - old.QuestExpiry != new.QuestExpiry || - old.CellId != new.CellId || - old.Deleted != new.Deleted || - old.LureId != new.LureId || - old.FirstSeenTimestamp != new.FirstSeenTimestamp || - old.SponsorId != new.SponsorId || - old.PartnerId != new.PartnerId || - old.ArScanEligible != new.ArScanEligible || - old.PowerUpLevel != new.PowerUpLevel || - old.PowerUpPoints != new.PowerUpPoints || - old.PowerUpEndTimestamp != new.PowerUpEndTimestamp || - old.AlternativeQuestType != new.AlternativeQuestType || - old.AlternativeQuestTimestamp != new.AlternativeQuestTimestamp || - old.AlternativeQuestTarget != new.AlternativeQuestTarget || - old.AlternativeQuestConditions != new.AlternativeQuestConditions || - old.AlternativeQuestRewards != new.AlternativeQuestRewards || - old.AlternativeQuestTemplate != new.AlternativeQuestTemplate || - old.AlternativeQuestTitle != new.AlternativeQuestTitle || - old.AlternativeQuestExpiry != new.AlternativeQuestExpiry || - old.Description != new.Description || - !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) || - old.ShowcaseRankingStandard != new.ShowcaseRankingStandard || - old.ShowcaseFocus != new.ShowcaseFocus || - old.ShowcaseRankings != new.ShowcaseRankings || - old.ShowcaseExpiry != new.ShowcaseExpiry +// IsNewRecord returns true if this is a new record (not yet in DB) +func (p *Pokestop) IsNewRecord() bool { + return p.newRecord } -var LureTime int64 = 1800 +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (p *Pokestop) snapshotOldValues() { + p.oldValues = PokestopOldValues{ + QuestType: p.QuestType, + AlternativeQuestType: p.AlternativeQuestType, + LureExpireTimestamp: p.LureExpireTimestamp, + LureId: p.LureId, + PowerUpEndTimestamp: p.PowerUpEndTimestamp, + Name: p.Name, + Url: p.Url, + Description: p.Description, + Lat: p.Lat, + Lon: p.Lon, + } +} -func (stop *Pokestop) updatePokestopFromFort(fortData *pogo.PokemonFortProto, cellId uint64, now int64) *Pokestop { - stop.Id = fortData.FortId - stop.Lat = fortData.Latitude - stop.Lon = fortData.Longitude +// Lock acquires the Pokestop's mutex +func (p *Pokestop) Lock() { + p.mu.Lock() +} - stop.PartnerId = null.NewString(fortData.PartnerId, fortData.PartnerId != "") - stop.SponsorId = null.IntFrom(int64(fortData.Sponsor)) - stop.Enabled = null.BoolFrom(fortData.Enabled) - stop.ArScanEligible = null.IntFrom(util.BoolToInt[int64](fortData.IsArScanEligible)) - stop.PowerUpPoints = null.IntFrom(int64(fortData.PowerUpProgressPoints)) - stop.PowerUpLevel, stop.PowerUpEndTimestamp = calculatePowerUpPoints(fortData) +// Unlock releases the Pokestop's mutex +func (p *Pokestop) Unlock() { + p.mu.Unlock() +} - // lasModifiedMs is also modified when incident happens - lastModifiedTimestamp := fortData.LastModifiedMs / 1000 - stop.LastModifiedTimestamp = null.IntFrom(lastModifiedTimestamp) +// --- Set methods with dirty tracking --- - if len(fortData.ActiveFortModifier) > 0 { - lureId := int16(fortData.ActiveFortModifier[0]) - if lureId >= 501 && lureId <= 510 { - lureEnd := lastModifiedTimestamp + LureTime - oldLureEnd := stop.LureExpireTimestamp.ValueOrZero() - if stop.LureId != lureId { - stop.LureExpireTimestamp = null.IntFrom(lureEnd) - stop.LureId = lureId - } else { - // wait some time after lure end before a restart in case of timing issue - if now > oldLureEnd+30 { - for now > lureEnd { - lureEnd += LureTime - } - // lure needs to be restarted - stop.LureExpireTimestamp = null.IntFrom(lureEnd) - } - } +func (p *Pokestop) SetId(v string) { + if p.Id != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Id:%s->%s", p.Id, v)) } + p.Id = v + p.dirty = true } +} - if fortData.ImageUrl != "" { - stop.Url = null.StringFrom(fortData.ImageUrl) - } - stop.CellId = null.IntFrom(int64(cellId)) - - if stop.Deleted { - stop.Deleted = false - log.Warnf("Cleared Stop with id '%s' is found again in GMO, therefore un-deleted", stop.Id) - // Restore in fort tracker if enabled - if fortTracker != nil { - fortTracker.RestoreFort(stop.Id, cellId, false, time.Now().Unix()) +func (p *Pokestop) SetLat(v float64) { + if !floatAlmostEqual(p.Lat, v, floatTolerance) { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Lat:%f->%f", p.Lat, v)) } + p.Lat = v + p.dirty = true } - return stop } -func (stop *Pokestop) updatePokestopFromQuestProto(questProto *pogo.FortSearchOutProto, haveAr bool) string { - - if questProto.ChallengeQuest == nil { - log.Debugf("Received blank quest") - return "Blank quest" - } - questData := questProto.ChallengeQuest.Quest - questTitle := questProto.ChallengeQuest.QuestDisplay.Description - questType := int64(questData.QuestType) - questTarget := int64(questData.Goal.Target) - questTemplate := strings.ToLower(questData.TemplateId) - - conditions := []map[string]any{} - rewards := []map[string]any{} - - for _, conditionData := range questData.Goal.Condition { - condition := make(map[string]any) - infoData := make(map[string]any) - condition["type"] = int(conditionData.Type) - switch conditionData.Type { - case pogo.QuestConditionProto_WITH_BADGE_TYPE: - info := conditionData.GetWithBadgeType() - infoData["amount"] = info.Amount - infoData["badge_rank"] = info.BadgeRank - badgeTypeById := []int{} - for _, badge := range info.BadgeType { - badgeTypeById = append(badgeTypeById, int(badge)) - } - infoData["badge_types"] = badgeTypeById - - case pogo.QuestConditionProto_WITH_ITEM: - info := conditionData.GetWithItem() - if int(info.Item) != 0 { - infoData["item_id"] = int(info.Item) - } - case pogo.QuestConditionProto_WITH_RAID_LEVEL: - info := conditionData.GetWithRaidLevel() - raidLevelById := []int{} - for _, raidLevel := range info.RaidLevel { - raidLevelById = append(raidLevelById, int(raidLevel)) - } - infoData["raid_levels"] = raidLevelById - case pogo.QuestConditionProto_WITH_POKEMON_TYPE: - info := conditionData.GetWithPokemonType() - pokemonTypesById := []int{} - for _, t := range info.PokemonType { - pokemonTypesById = append(pokemonTypesById, int(t)) - } - infoData["pokemon_type_ids"] = pokemonTypesById - case pogo.QuestConditionProto_WITH_POKEMON_CATEGORY: - info := conditionData.GetWithPokemonCategory() - if info.CategoryName != "" { - infoData["category_name"] = info.CategoryName - } - pokemonById := []int{} - for _, pokemon := range info.PokemonIds { - pokemonById = append(pokemonById, int(pokemon)) - } - infoData["pokemon_ids"] = pokemonById - case pogo.QuestConditionProto_WITH_WIN_RAID_STATUS: - case pogo.QuestConditionProto_WITH_THROW_TYPE: - info := conditionData.GetWithThrowType() - if int(info.GetThrowType()) != 0 { // TODO: RDM has ThrowType here, ensure it is the same thing - infoData["throw_type_id"] = int(info.GetThrowType()) - } - infoData["hit"] = info.GetHit() - case pogo.QuestConditionProto_WITH_THROW_TYPE_IN_A_ROW: - info := conditionData.GetWithThrowType() - if int(info.GetThrowType()) != 0 { - infoData["throw_type_id"] = int(info.GetThrowType()) - } - infoData["hit"] = info.GetHit() - case pogo.QuestConditionProto_WITH_LOCATION: - info := conditionData.GetWithLocation() - infoData["cell_ids"] = info.S2CellId - case pogo.QuestConditionProto_WITH_DISTANCE: - info := conditionData.GetWithDistance() - infoData["distance"] = info.DistanceKm - case pogo.QuestConditionProto_WITH_POKEMON_ALIGNMENT: - info := conditionData.GetWithPokemonAlignment() - alignmentIds := []int{} - for _, alignment := range info.Alignment { - alignmentIds = append(alignmentIds, int(alignment)) - } - infoData["alignment_ids"] = alignmentIds - case pogo.QuestConditionProto_WITH_INVASION_CHARACTER: - info := conditionData.GetWithInvasionCharacter() - characterCategoryIds := []int{} - for _, characterCategory := range info.Category { - characterCategoryIds = append(characterCategoryIds, int(characterCategory)) - } - infoData["character_category_ids"] = characterCategoryIds - case pogo.QuestConditionProto_WITH_NPC_COMBAT: - info := conditionData.GetWithNpcCombat() - infoData["win"] = info.RequiresWin - infoData["template_ids"] = info.CombatNpcTrainerId - case pogo.QuestConditionProto_WITH_PLAYER_LEVEL: - info := conditionData.GetWithPlayerLevel() - infoData["level"] = info.Level - case pogo.QuestConditionProto_WITH_BUDDY: - info := conditionData.GetWithBuddy() - if info != nil { - infoData["min_buddy_level"] = int(info.MinBuddyLevel) - infoData["must_be_on_map"] = info.MustBeOnMap - } else { - infoData["min_buddy_level"] = 0 - infoData["must_be_on_map"] = false - } - case pogo.QuestConditionProto_WITH_DAILY_BUDDY_AFFECTION: - info := conditionData.GetWithDailyBuddyAffection() - infoData["min_buddy_affection_earned_today"] = info.MinBuddyAffectionEarnedToday - case pogo.QuestConditionProto_WITH_TEMP_EVO_POKEMON: - info := conditionData.GetWithTempEvoId() - tempEvoIds := []int{} - for _, evolution := range info.MegaForm { - tempEvoIds = append(tempEvoIds, int(evolution)) - } - infoData["raid_pokemon_evolutions"] = tempEvoIds - case pogo.QuestConditionProto_WITH_ITEM_TYPE: - info := conditionData.GetWithItemType() - itemTypes := []int{} - for _, itemType := range info.ItemType { - itemTypes = append(itemTypes, int(itemType)) - } - infoData["item_type_ids"] = itemTypes - case pogo.QuestConditionProto_WITH_RAID_ELAPSED_TIME: - info := conditionData.GetWithElapsedTime() - infoData["time"] = int64(info.ElapsedTimeMs) / 1000 - case pogo.QuestConditionProto_WITH_WIN_GYM_BATTLE_STATUS: - case pogo.QuestConditionProto_WITH_SUPER_EFFECTIVE_CHARGE: - case pogo.QuestConditionProto_WITH_UNIQUE_POKESTOP: - case pogo.QuestConditionProto_WITH_QUEST_CONTEXT: - case pogo.QuestConditionProto_WITH_WIN_BATTLE_STATUS: - case pogo.QuestConditionProto_WITH_CURVE_BALL: - case pogo.QuestConditionProto_WITH_NEW_FRIEND: - case pogo.QuestConditionProto_WITH_DAYS_IN_A_ROW: - case pogo.QuestConditionProto_WITH_WEATHER_BOOST: - case pogo.QuestConditionProto_WITH_DAILY_CAPTURE_BONUS: - case pogo.QuestConditionProto_WITH_DAILY_SPIN_BONUS: - case pogo.QuestConditionProto_WITH_UNIQUE_POKEMON: - case pogo.QuestConditionProto_WITH_BUDDY_INTERESTING_POI: - case pogo.QuestConditionProto_WITH_POKEMON_LEVEL: - case pogo.QuestConditionProto_WITH_SINGLE_DAY: - case pogo.QuestConditionProto_WITH_UNIQUE_POKEMON_TEAM: - case pogo.QuestConditionProto_WITH_MAX_CP: - case pogo.QuestConditionProto_WITH_LUCKY_POKEMON: - case pogo.QuestConditionProto_WITH_LEGENDARY_POKEMON: - case pogo.QuestConditionProto_WITH_GBL_RANK: - case pogo.QuestConditionProto_WITH_CATCHES_IN_A_ROW: - case pogo.QuestConditionProto_WITH_ENCOUNTER_TYPE: - case pogo.QuestConditionProto_WITH_COMBAT_TYPE: - case pogo.QuestConditionProto_WITH_GEOTARGETED_POI: - case pogo.QuestConditionProto_WITH_FRIEND_LEVEL: - case pogo.QuestConditionProto_WITH_STICKER: - case pogo.QuestConditionProto_WITH_POKEMON_CP: - case pogo.QuestConditionProto_WITH_RAID_LOCATION: - case pogo.QuestConditionProto_WITH_FRIENDS_RAID: - case pogo.QuestConditionProto_WITH_POKEMON_COSTUME: - default: - break +func (p *Pokestop) SetLon(v float64) { + if !floatAlmostEqual(p.Lon, v, floatTolerance) { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Lon:%f->%f", p.Lon, v)) } + p.Lon = v + p.dirty = true + } +} - if infoData != nil { - condition["info"] = infoData +func (p *Pokestop) SetName(v null.String) { + if p.Name != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Name:%s->%s", FormatNull(p.Name), FormatNull(v))) } - conditions = append(conditions, condition) + p.Name = v + p.dirty = true } +} - for _, rewardData := range questData.QuestRewards { - reward := make(map[string]any) - infoData := make(map[string]any) - reward["type"] = int(rewardData.Type) - switch rewardData.Type { - case pogo.QuestRewardProto_EXPERIENCE: - infoData["amount"] = rewardData.GetExp() - case pogo.QuestRewardProto_ITEM: - info := rewardData.GetItem() - infoData["amount"] = info.Amount - infoData["item_id"] = int(info.Item) - case pogo.QuestRewardProto_STARDUST: - infoData["amount"] = rewardData.GetStardust() - case pogo.QuestRewardProto_CANDY: - info := rewardData.GetCandy() - infoData["amount"] = info.Amount - infoData["pokemon_id"] = int(info.PokemonId) - case pogo.QuestRewardProto_XL_CANDY: - info := rewardData.GetXlCandy() - infoData["amount"] = info.Amount - infoData["pokemon_id"] = int(info.PokemonId) - case pogo.QuestRewardProto_POKEMON_ENCOUNTER: - info := rewardData.GetPokemonEncounter() - if info.IsHiddenDitto { - infoData["pokemon_id"] = 132 - infoData["pokemon_id_display"] = int(info.GetPokemonId()) - } else { - infoData["pokemon_id"] = int(info.GetPokemonId()) - } - if info.ShinyProbability > 0.0 { - infoData["shiny_probability"] = info.ShinyProbability - } - if display := info.PokemonDisplay; display != nil { - if costumeId := int(display.Costume); costumeId != 0 { - infoData["costume_id"] = costumeId - } - if formId := int(display.Form); formId != 0 { - infoData["form_id"] = formId - } - if genderId := int(display.Gender); genderId != 0 { - infoData["gender_id"] = genderId - } - if display.Shiny { - infoData["shiny"] = display.Shiny - } - if background := util.ExtractBackgroundFromDisplay(display); background != nil { - infoData["background"] = background - } - if breadMode := int(display.BreadModeEnum); breadMode != 0 { - infoData["bread_mode"] = breadMode - } - } else { - - } - case pogo.QuestRewardProto_POKECOIN: - infoData["amount"] = rewardData.GetPokecoin() - case pogo.QuestRewardProto_STICKER: - info := rewardData.GetSticker() - infoData["amount"] = info.Amount - infoData["sticker_id"] = info.StickerId - case pogo.QuestRewardProto_MEGA_RESOURCE: - info := rewardData.GetMegaResource() - infoData["amount"] = info.Amount - infoData["pokemon_id"] = int(info.PokemonId) - case pogo.QuestRewardProto_AVATAR_CLOTHING: - case pogo.QuestRewardProto_QUEST: - case pogo.QuestRewardProto_LEVEL_CAP: - case pogo.QuestRewardProto_INCIDENT: - case pogo.QuestRewardProto_PLAYER_ATTRIBUTE: - default: - break - +func (p *Pokestop) SetUrl(v null.String) { + if p.Url != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Url:%s->%s", FormatNull(p.Url), FormatNull(v))) } - reward["info"] = infoData - rewards = append(rewards, reward) + p.Url = v + p.dirty = true } +} - questConditions, _ := json.Marshal(conditions) - questRewards, _ := json.Marshal(rewards) - questTimestamp := time.Now().Unix() - - questExpiry := null.NewInt(0, false) - - stopTimezone := tz.SearchTimezone(stop.Lat, stop.Lon) - if stopTimezone != "" { - loc, err := time.LoadLocation(stopTimezone) - if err != nil { - log.Warnf("Unrecognised time zone %s at %f,%f", stopTimezone, stop.Lat, stop.Lon) - } else { - year, month, day := time.Now().In(loc).Date() - t := time.Date(year, month, day, 0, 0, 0, 0, loc).AddDate(0, 0, 1) - unixTime := t.Unix() - questExpiry = null.IntFrom(unixTime) +func (p *Pokestop) SetLureExpireTimestamp(v null.Int) { + if p.LureExpireTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LureExpireTimestamp:%s->%s", FormatNull(p.LureExpireTimestamp), FormatNull(v))) } + p.LureExpireTimestamp = v + p.dirty = true } +} - if questExpiry.Valid == false { - questExpiry = null.IntFrom(time.Now().Unix() + 24*60*60) // Set expiry to 24 hours from now +func (p *Pokestop) SetLastModifiedTimestamp(v null.Int) { + if p.LastModifiedTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LastModifiedTimestamp:%s->%s", FormatNull(p.LastModifiedTimestamp), FormatNull(v))) + } + p.LastModifiedTimestamp = v + p.dirty = true } +} - if !haveAr { - stop.AlternativeQuestType = null.IntFrom(questType) - stop.AlternativeQuestTarget = null.IntFrom(questTarget) - stop.AlternativeQuestTemplate = null.StringFrom(questTemplate) - stop.AlternativeQuestTitle = null.StringFrom(questTitle) - stop.AlternativeQuestConditions = null.StringFrom(string(questConditions)) - stop.AlternativeQuestRewards = null.StringFrom(string(questRewards)) - stop.AlternativeQuestTimestamp = null.IntFrom(questTimestamp) - stop.AlternativeQuestExpiry = questExpiry - } else { - stop.QuestType = null.IntFrom(questType) - stop.QuestTarget = null.IntFrom(questTarget) - stop.QuestTemplate = null.StringFrom(questTemplate) - stop.QuestTitle = null.StringFrom(questTitle) - stop.QuestConditions = null.StringFrom(string(questConditions)) - stop.QuestRewards = null.StringFrom(string(questRewards)) - stop.QuestTimestamp = null.IntFrom(questTimestamp) - stop.QuestExpiry = questExpiry +func (p *Pokestop) SetEnabled(v null.Bool) { + if p.Enabled != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Enabled:%s->%s", FormatNull(p.Enabled), FormatNull(v))) + } + p.Enabled = v + p.dirty = true } - - return questTitle } -func (stop *Pokestop) updatePokestopFromFortDetailsProto(fortData *pogo.FortDetailsOutProto) *Pokestop { - stop.Id = fortData.Id - stop.Lat = fortData.Latitude - stop.Lon = fortData.Longitude - if len(fortData.ImageUrl) > 0 { - stop.Url = null.StringFrom(fortData.ImageUrl[0]) +func (p *Pokestop) SetQuestType(v null.Int) { + if p.QuestType != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestType:%s->%s", FormatNull(p.QuestType), FormatNull(v))) + } + p.QuestType = v + p.dirty = true } - stop.Name = null.StringFrom(fortData.Name) +} - if fortData.Description == "" { - stop.Description = null.NewString("", false) - } else { - stop.Description = null.StringFrom(fortData.Description) +func (p *Pokestop) SetQuestTimestamp(v null.Int) { + if p.QuestTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestTimestamp:%s->%s", FormatNull(p.QuestTimestamp), FormatNull(v))) + } + p.QuestTimestamp = v + p.dirty = true } +} - if fortData.Modifier != nil && len(fortData.Modifier) > 0 { - // DeployingPlayerCodename contains the name of the player if we want that - lureId := int16(fortData.Modifier[0].ModifierType) - lureExpiry := fortData.Modifier[0].ExpirationTimeMs / 1000 - - stop.LureId = lureId - stop.LureExpireTimestamp = null.IntFrom(lureExpiry) +func (p *Pokestop) SetQuestTarget(v null.Int) { + if p.QuestTarget != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestTarget:%s->%s", FormatNull(p.QuestTarget), FormatNull(v))) + } + p.QuestTarget = v + p.dirty = true } - - return stop } -func (stop *Pokestop) updatePokestopFromGetMapFortsOutProto(fortData *pogo.GetMapFortsOutProto_FortProto) *Pokestop { - stop.Id = fortData.Id - stop.Lat = fortData.Latitude - stop.Lon = fortData.Longitude - - if len(fortData.Image) > 0 { - stop.Url = null.StringFrom(fortData.Image[0].Url) - } - stop.Name = null.StringFrom(fortData.Name) - if stop.Deleted { - log.Debugf("Cleared Stop with id '%s' is found again in GMF, therefore kept deleted", stop.Id) +func (p *Pokestop) SetQuestConditions(v null.String) { + if p.QuestConditions != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestConditions:%s->%s", FormatNull(p.QuestConditions), FormatNull(v))) + } + p.QuestConditions = v + p.dirty = true } - return stop } -func (stop *Pokestop) updatePokestopFromGetContestDataOutProto(contest *pogo.ContestProto) { - stop.ShowcaseRankingStandard = null.IntFrom(int64(contest.GetMetric().GetRankingStandard())) - stop.ShowcaseExpiry = null.IntFrom(contest.GetSchedule().GetContestCycle().GetEndTimeMs() / 1000) - - focusStore := createFocusStoreFromContestProto(contest) - - if len(focusStore) > 1 { - log.Warnf("SHOWCASE: we got more than one showcase focus: %v", focusStore) +func (p *Pokestop) SetQuestRewards(v null.String) { + if p.QuestRewards != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestRewards:%s->%s", FormatNull(p.QuestRewards), FormatNull(v))) + } + p.QuestRewards = v + p.dirty = true } +} - for key, focus := range focusStore { - focus["type"] = key - jsonBytes, err := json.Marshal(focus) - if err != nil { - log.Errorf("SHOWCASE: Stop '%s' - Focus '%v' marshalling failed: %s", stop.Id, focus, err) +func (p *Pokestop) SetQuestTemplate(v null.String) { + if p.QuestTemplate != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestTemplate:%s->%s", FormatNull(p.QuestTemplate), FormatNull(v))) } - stop.ShowcaseFocus = null.StringFrom(string(jsonBytes)) - // still support old format - probably still required to filter in external tools - stop.extractShowcasePokemonInfoDeprecated(key, focus) + p.QuestTemplate = v + p.dirty = true } } -func (stop *Pokestop) updatePokestopFromGetPokemonSizeContestEntryOutProto(contestData *pogo.GetPokemonSizeLeaderboardEntryOutProto) { - type contestEntry struct { - Rank int `json:"rank"` - Score float64 `json:"score"` - PokemonId int `json:"pokemon_id"` - Form int `json:"form"` - Costume int `json:"costume"` - Gender int `json:"gender"` - Shiny bool `json:"shiny"` - TempEvolution int `json:"temp_evolution"` - TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms"` - Alignment int `json:"alignment"` - Badge int `json:"badge"` - Background *int64 `json:"background,omitempty"` - } - type contestJson struct { - TotalEntries int `json:"total_entries"` - LastUpdate int64 `json:"last_update"` - ContestEntries []contestEntry `json:"contest_entries"` +func (p *Pokestop) SetQuestTitle(v null.String) { + if p.QuestTitle != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestTitle:%s->%s", FormatNull(p.QuestTitle), FormatNull(v))) + } + p.QuestTitle = v + p.dirty = true } +} - j := contestJson{LastUpdate: time.Now().Unix()} - j.TotalEntries = int(contestData.TotalEntries) - - for _, entry := range contestData.GetContestEntries() { - rank := entry.GetRank() - if rank > 3 { - break +func (p *Pokestop) SetQuestExpiry(v null.Int) { + if p.QuestExpiry != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("QuestExpiry:%s->%s", FormatNull(p.QuestExpiry), FormatNull(v))) } - j.ContestEntries = append(j.ContestEntries, contestEntry{ - Rank: int(rank), - Score: entry.GetScore(), - PokemonId: int(entry.GetPokedexId()), - Form: int(entry.GetPokemonDisplay().Form), - Costume: int(entry.GetPokemonDisplay().Costume), - Gender: int(entry.GetPokemonDisplay().Gender), - Shiny: entry.GetPokemonDisplay().Shiny, - TempEvolution: int(entry.GetPokemonDisplay().CurrentTempEvolution), - TempEvolutionFinishMs: entry.GetPokemonDisplay().TemporaryEvolutionFinishMs, - Alignment: int(entry.GetPokemonDisplay().Alignment), - Badge: int(entry.GetPokemonDisplay().PokemonBadge), - Background: util.ExtractBackgroundFromDisplay(entry.PokemonDisplay), - }) - + p.QuestExpiry = v + p.dirty = true } - jsonString, _ := json.Marshal(j) - stop.ShowcaseRankings = null.StringFrom(string(jsonString)) } -func createPokestopFortWebhooks(oldStop *Pokestop, stop *Pokestop) { - fort := InitWebHookFortFromPokestop(stop) - oldFort := InitWebHookFortFromPokestop(oldStop) - if oldStop == nil { - CreateFortWebHooks(oldFort, fort, NEW) - } else { - CreateFortWebHooks(oldFort, fort, EDIT) +func (p *Pokestop) SetCellId(v null.Int) { + if p.CellId != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("CellId:%s->%s", FormatNull(p.CellId), FormatNull(v))) + } + p.CellId = v + p.dirty = true } } -func createPokestopWebhooks(oldStop *Pokestop, stop *Pokestop) { - - areas := MatchStatsGeofence(stop.Lat, stop.Lon) - - if stop.AlternativeQuestType.Valid && (oldStop == nil || stop.AlternativeQuestType != oldStop.AlternativeQuestType) { - questHook := map[string]any{ - "pokestop_id": stop.Id, - "latitude": stop.Lat, - "longitude": stop.Lon, - "pokestop_name": func() string { - if stop.Name.Valid { - return stop.Name.String - } else { - return "Unknown" - } - }(), - "type": stop.AlternativeQuestType, - "target": stop.AlternativeQuestTarget, - "template": stop.AlternativeQuestTemplate, - "title": stop.AlternativeQuestTitle, - "conditions": json.RawMessage(stop.AlternativeQuestConditions.ValueOrZero()), - "rewards": json.RawMessage(stop.AlternativeQuestRewards.ValueOrZero()), - "updated": stop.Updated, - "ar_scan_eligible": stop.ArScanEligible.ValueOrZero(), - "pokestop_url": stop.Url.ValueOrZero(), - "with_ar": false, +func (p *Pokestop) SetDeleted(v bool) { + if p.Deleted != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Deleted:%t->%t", p.Deleted, v)) } - webhooksSender.AddMessage(webhooks.Quest, questHook, areas) + p.Deleted = v + p.dirty = true } +} - if stop.QuestType.Valid && (oldStop == nil || stop.QuestType != oldStop.QuestType) { - questHook := map[string]any{ - "pokestop_id": stop.Id, - "latitude": stop.Lat, - "longitude": stop.Lon, - "pokestop_name": func() string { - if stop.Name.Valid { - return stop.Name.String - } else { - return "Unknown" - } - }(), - "type": stop.QuestType, - "target": stop.QuestTarget, - "template": stop.QuestTemplate, - "title": stop.QuestTitle, - "conditions": json.RawMessage(stop.QuestConditions.ValueOrZero()), - "rewards": json.RawMessage(stop.QuestRewards.ValueOrZero()), - "updated": stop.Updated, - "ar_scan_eligible": stop.ArScanEligible.ValueOrZero(), - "pokestop_url": stop.Url.ValueOrZero(), - "with_ar": true, +func (p *Pokestop) SetLureId(v int16) { + if p.LureId != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("LureId:%d->%d", p.LureId, v)) } - webhooksSender.AddMessage(webhooks.Quest, questHook, areas) + p.LureId = v + p.dirty = true } - if (oldStop == nil && (stop.LureId != 0 || stop.PowerUpEndTimestamp.ValueOrZero() != 0)) || (oldStop != nil && ((stop.LureExpireTimestamp != oldStop.LureExpireTimestamp && stop.LureId != 0) || stop.PowerUpEndTimestamp != oldStop.PowerUpEndTimestamp)) { - pokestopHook := map[string]any{ - "pokestop_id": stop.Id, - "latitude": stop.Lat, - "longitude": stop.Lon, - "name": func() string { - if stop.Name.Valid { - return stop.Name.String - } else { - return "Unknown" - } - }(), - "url": stop.Url.ValueOrZero(), - "lure_expiration": stop.LureExpireTimestamp.ValueOrZero(), - "last_modified": stop.LastModifiedTimestamp.ValueOrZero(), - "enabled": stop.Enabled.ValueOrZero(), - "lure_id": stop.LureId, - "ar_scan_eligible": stop.ArScanEligible.ValueOrZero(), - "power_up_level": stop.PowerUpLevel.ValueOrZero(), - "power_up_points": stop.PowerUpPoints.ValueOrZero(), - "power_up_end_timestamp": stop.PowerUpPoints.ValueOrZero(), - "updated": stop.Updated, - "showcase_focus": stop.ShowcaseFocus, - "showcase_pokemon_id": stop.ShowcasePokemon, - "showcase_pokemon_form_id": stop.ShowcasePokemonForm, - "showcase_pokemon_type_id": stop.ShowcasePokemonType, - "showcase_ranking_standard": stop.ShowcaseRankingStandard, - "showcase_expiry": stop.ShowcaseExpiry, - "showcase_rankings": func() any { - if !stop.ShowcaseRankings.Valid { - return nil - } else { - return json.RawMessage(stop.ShowcaseRankings.ValueOrZero()) - } - }(), - } +} - webhooksSender.AddMessage(webhooks.Pokestop, pokestopHook, areas) +func (p *Pokestop) SetFirstSeenTimestamp(v int16) { + if p.FirstSeenTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("FirstSeenTimestamp:%d->%d", p.FirstSeenTimestamp, v)) + } + p.FirstSeenTimestamp = v + p.dirty = true } } -func savePokestopRecord(ctx context.Context, db db.DbDetails, pokestop *Pokestop) { - oldPokestop, _ := GetPokestopRecord(ctx, db, pokestop.Id) - now := time.Now().Unix() - if oldPokestop != nil && !hasChangesPokestop(oldPokestop, pokestop) { - if oldPokestop.Updated > now-900 { - // if a pokestop is unchanged, but we did see it again after 15 minutes, then save again - return +func (p *Pokestop) SetSponsorId(v null.Int) { + if p.SponsorId != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("SponsorId:%s->%s", FormatNull(p.SponsorId), FormatNull(v))) } + p.SponsorId = v + p.dirty = true } - pokestop.Updated = now - - //log.Traceln(cmp.Diff(oldPokestop, pokestop)) - - if oldPokestop == nil { - res, err := db.GeneralDb.NamedExecContext(ctx, ` - INSERT INTO pokestop ( - id, lat, lon, name, url, enabled, lure_expire_timestamp, last_modified_timestamp, quest_type, - quest_timestamp, quest_target, quest_conditions, quest_rewards, quest_template, quest_title, - alternative_quest_type, alternative_quest_timestamp, alternative_quest_target, - alternative_quest_conditions, alternative_quest_rewards, alternative_quest_template, - alternative_quest_title, cell_id, lure_id, sponsor_id, partner_id, ar_scan_eligible, - power_up_points, power_up_level, power_up_end_timestamp, updated, first_seen_timestamp, - quest_expiry, alternative_quest_expiry, description, showcase_focus, showcase_pokemon_id, - showcase_pokemon_form_id, showcase_pokemon_type_id, showcase_ranking_standard, showcase_expiry, showcase_rankings - ) - VALUES ( - :id, :lat, :lon, :name, :url, :enabled, :lure_expire_timestamp, :last_modified_timestamp, :quest_type, - :quest_timestamp, :quest_target, :quest_conditions, :quest_rewards, :quest_template, :quest_title, - :alternative_quest_type, :alternative_quest_timestamp, :alternative_quest_target, - :alternative_quest_conditions, :alternative_quest_rewards, :alternative_quest_template, - :alternative_quest_title, :cell_id, :lure_id, :sponsor_id, :partner_id, :ar_scan_eligible, - :power_up_points, :power_up_level, :power_up_end_timestamp, - UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), - :quest_expiry, :alternative_quest_expiry, :description, :showcase_focus, :showcase_pokemon_id, - :showcase_pokemon_form_id, :showcase_pokemon_type_id, :showcase_ranking_standard, :showcase_expiry, :showcase_rankings)`, - pokestop) +} - statsCollector.IncDbQuery("insert pokestop", err) - //log.Debugf("Insert pokestop %s %+v", pokestop.Id, pokestop) - if err != nil { - log.Errorf("insert pokestop %s: %s", pokestop.Id, err) - return - } - _ = res - } else { - res, err := db.GeneralDb.NamedExecContext(ctx, ` - UPDATE pokestop SET - lat = :lat, - lon = :lon, - name = :name, - url = :url, - enabled = :enabled, - lure_expire_timestamp = :lure_expire_timestamp, - last_modified_timestamp = :last_modified_timestamp, - updated = :updated, - quest_type = :quest_type, - quest_timestamp = :quest_timestamp, - quest_target = :quest_target, - quest_conditions = :quest_conditions, - quest_rewards = :quest_rewards, - quest_template = :quest_template, - quest_title = :quest_title, - alternative_quest_type = :alternative_quest_type, - alternative_quest_timestamp = :alternative_quest_timestamp, - alternative_quest_target = :alternative_quest_target, - alternative_quest_conditions = :alternative_quest_conditions, - alternative_quest_rewards = :alternative_quest_rewards, - alternative_quest_template = :alternative_quest_template, - alternative_quest_title = :alternative_quest_title, - cell_id = :cell_id, - lure_id = :lure_id, - deleted = :deleted, - sponsor_id = :sponsor_id, - partner_id = :partner_id, - ar_scan_eligible = :ar_scan_eligible, - power_up_points = :power_up_points, - power_up_level = :power_up_level, - power_up_end_timestamp = :power_up_end_timestamp, - quest_expiry = :quest_expiry, - alternative_quest_expiry = :alternative_quest_expiry, - description = :description, - showcase_focus = :showcase_focus, - showcase_pokemon_id = :showcase_pokemon_id, - showcase_pokemon_form_id = :showcase_pokemon_form_id, - showcase_pokemon_type_id = :showcase_pokemon_type_id, - showcase_ranking_standard = :showcase_ranking_standard, - showcase_expiry = :showcase_expiry, - showcase_rankings = :showcase_rankings - WHERE id = :id`, - pokestop, - ) - statsCollector.IncDbQuery("update pokestop", err) - //log.Debugf("Update pokestop %s %+v", pokestop.Id, pokestop) - if err != nil { - log.Errorf("update pokestop %s: %s", pokestop.Id, err) - return +func (p *Pokestop) SetPartnerId(v null.String) { + if p.PartnerId != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PartnerId:%s->%s", FormatNull(p.PartnerId), FormatNull(v))) } - _ = res + p.PartnerId = v + p.dirty = true } - pokestopCache.Set(pokestop.Id, *pokestop, ttlcache.DefaultTTL) - createPokestopWebhooks(oldPokestop, pokestop) - createPokestopFortWebhooks(oldPokestop, pokestop) } -func updatePokestopGetMapFortCache(pokestop *Pokestop) { - storedGetMapFort := getMapFortsCache.Get(pokestop.Id) - if storedGetMapFort != nil { - getMapFort := storedGetMapFort.Value() - getMapFortsCache.Delete(pokestop.Id) - pokestop.updatePokestopFromGetMapFortsOutProto(getMapFort) - log.Debugf("Updated Gym using stored getMapFort: %s", pokestop.Id) +func (p *Pokestop) SetArScanEligible(v null.Int) { + if p.ArScanEligible != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ArScanEligible:%s->%s", FormatNull(p.ArScanEligible), FormatNull(v))) + } + p.ArScanEligible = v + p.dirty = true } } -func UpdatePokestopRecordWithFortDetailsOutProto(ctx context.Context, db db.DbDetails, fort *pogo.FortDetailsOutProto) string { - pokestopMutex, _ := pokestopStripedMutex.GetLock(fort.Id) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() - - pokestop, err := GetPokestopRecord(ctx, db, fort.Id) // should check error - if err != nil { - log.Printf("Update pokestop %s", err) - return fmt.Sprintf("Error %s", err) +func (p *Pokestop) SetPowerUpLevel(v null.Int) { + if p.PowerUpLevel != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PowerUpLevel:%s->%s", FormatNull(p.PowerUpLevel), FormatNull(v))) + } + p.PowerUpLevel = v + p.dirty = true } +} - if pokestop == nil { - pokestop = &Pokestop{} +func (p *Pokestop) SetPowerUpPoints(v null.Int) { + if p.PowerUpPoints != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PowerUpPoints:%s->%s", FormatNull(p.PowerUpPoints), FormatNull(v))) + } + p.PowerUpPoints = v + p.dirty = true } - pokestop.updatePokestopFromFortDetailsProto(fort) - - updatePokestopGetMapFortCache(pokestop) - savePokestopRecord(ctx, db, pokestop) - return fmt.Sprintf("%s %s", fort.Id, fort.Name) } -func UpdatePokestopWithQuest(ctx context.Context, db db.DbDetails, quest *pogo.FortSearchOutProto, haveAr bool) string { - haveArStr := "NoAR" - if haveAr { - haveArStr = "AR" +func (p *Pokestop) SetPowerUpEndTimestamp(v null.Int) { + if p.PowerUpEndTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("PowerUpEndTimestamp:%s->%s", FormatNull(p.PowerUpEndTimestamp), FormatNull(v))) + } + p.PowerUpEndTimestamp = v + p.dirty = true } +} - if quest.ChallengeQuest == nil { - statsCollector.IncDecodeQuest("error", "no_quest") - return fmt.Sprintf("%s %s Blank quest", quest.FortId, haveArStr) +func (p *Pokestop) SetAlternativeQuestType(v null.Int) { + if p.AlternativeQuestType != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestType:%s->%s", FormatNull(p.AlternativeQuestType), FormatNull(v))) + } + p.AlternativeQuestType = v + p.dirty = true } +} - statsCollector.IncDecodeQuest("ok", haveArStr) - pokestopMutex, _ := pokestopStripedMutex.GetLock(quest.FortId) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() - - pokestop, err := GetPokestopRecord(ctx, db, quest.FortId) - if err != nil { - log.Printf("Update quest %s", err) - return fmt.Sprintf("error %s", err) +func (p *Pokestop) SetAlternativeQuestTimestamp(v null.Int) { + if p.AlternativeQuestTimestamp != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestTimestamp:%s->%s", FormatNull(p.AlternativeQuestTimestamp), FormatNull(v))) + } + p.AlternativeQuestTimestamp = v + p.dirty = true } +} - if pokestop == nil { - pokestop = &Pokestop{} +func (p *Pokestop) SetAlternativeQuestTarget(v null.Int) { + if p.AlternativeQuestTarget != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestTarget:%s->%s", FormatNull(p.AlternativeQuestTarget), FormatNull(v))) + } + p.AlternativeQuestTarget = v + p.dirty = true } - questTitle := pokestop.updatePokestopFromQuestProto(quest, haveAr) - - updatePokestopGetMapFortCache(pokestop) - savePokestopRecord(ctx, db, pokestop) - - areas := MatchStatsGeofence(pokestop.Lat, pokestop.Lon) - updateQuestStats(pokestop, haveAr, areas) - - return fmt.Sprintf("%s %s %s", quest.FortId, haveArStr, questTitle) } -func ClearQuestsWithinGeofence(ctx context.Context, dbDetails db.DbDetails, geofence *geojson.Feature) { - started := time.Now() - rows, err := db.RemoveQuests(ctx, dbDetails, geofence) - if err != nil { - log.Errorf("ClearQuest: Error removing quests: %s", err) - return +func (p *Pokestop) SetAlternativeQuestConditions(v null.String) { + if p.AlternativeQuestConditions != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestConditions:%s->%s", FormatNull(p.AlternativeQuestConditions), FormatNull(v))) + } + p.AlternativeQuestConditions = v + p.dirty = true } - ClearPokestopCache() - log.Infof("ClearQuest: Removed quests from %d pokestops in %s", rows, time.Since(started)) } -func GetQuestStatusWithGeofence(dbDetails db.DbDetails, geofence *geojson.Feature) db.QuestStatus { - res, err := db.GetQuestStatus(dbDetails, geofence) - if err != nil { - log.Errorf("QuestStatus: Error retrieving quests: %s", err) - return db.QuestStatus{} +func (p *Pokestop) SetAlternativeQuestRewards(v null.String) { + if p.AlternativeQuestRewards != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestRewards:%s->%s", FormatNull(p.AlternativeQuestRewards), FormatNull(v))) + } + p.AlternativeQuestRewards = v + p.dirty = true } - return res } -func UpdatePokestopRecordWithGetMapFortsOutProto(ctx context.Context, db db.DbDetails, mapFort *pogo.GetMapFortsOutProto_FortProto) (bool, string) { - pokestopMutex, _ := pokestopStripedMutex.GetLock(mapFort.Id) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() - - pokestop, err := GetPokestopRecord(ctx, db, mapFort.Id) - if err != nil { - log.Printf("Update pokestop %s", err) - return false, fmt.Sprintf("Error %s", err) +func (p *Pokestop) SetAlternativeQuestTemplate(v null.String) { + if p.AlternativeQuestTemplate != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestTemplate:%s->%s", FormatNull(p.AlternativeQuestTemplate), FormatNull(v))) + } + p.AlternativeQuestTemplate = v + p.dirty = true } +} - if pokestop == nil { - return false, "" +func (p *Pokestop) SetAlternativeQuestTitle(v null.String) { + if p.AlternativeQuestTitle != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestTitle:%s->%s", FormatNull(p.AlternativeQuestTitle), FormatNull(v))) + } + p.AlternativeQuestTitle = v + p.dirty = true } - - pokestop.updatePokestopFromGetMapFortsOutProto(mapFort) - savePokestopRecord(ctx, db, pokestop) - return true, fmt.Sprintf("%s %s", mapFort.Id, mapFort.Name) } -func GetPokestopPositions(details db.DbDetails, geofence *geojson.Feature) ([]db.QuestLocation, error) { - return db.GetPokestopPositions(details, geofence) +func (p *Pokestop) SetAlternativeQuestExpiry(v null.Int) { + if p.AlternativeQuestExpiry != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("AlternativeQuestExpiry:%s->%s", FormatNull(p.AlternativeQuestExpiry), FormatNull(v))) + } + p.AlternativeQuestExpiry = v + p.dirty = true + } } -func UpdatePokestopWithContestData(ctx context.Context, db db.DbDetails, request *pogo.GetContestDataProto, contestData *pogo.GetContestDataOutProto) string { - if contestData.ContestIncident == nil || len(contestData.ContestIncident.Contests) == 0 { - return "No contests found" +func (p *Pokestop) SetDescription(v null.String) { + if p.Description != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Description:%s->%s", FormatNull(p.Description), FormatNull(v))) + } + p.Description = v + p.dirty = true } +} - var fortId string - if request != nil { - fortId = request.FortId - } else { - fortId = getFortIdFromContest(contestData.ContestIncident.Contests[0].ContestId) +func (p *Pokestop) SetShowcaseFocus(v null.String) { + if p.ShowcaseFocus != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcaseFocus:%s->%s", FormatNull(p.ShowcaseFocus), FormatNull(v))) + } + p.ShowcaseFocus = v + p.dirty = true } +} - if fortId == "" { - return "No fortId found" +func (p *Pokestop) SetShowcasePokemon(v null.Int) { + if p.ShowcasePokemon != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcasePokemon:%s->%s", FormatNull(p.ShowcasePokemon), FormatNull(v))) + } + p.ShowcasePokemon = v + p.dirty = true } +} - if len(contestData.ContestIncident.Contests) > 1 { - log.Errorf("More than one contest found") - return fmt.Sprintf("More than one contest found in %s", fortId) +func (p *Pokestop) SetShowcasePokemonForm(v null.Int) { + if p.ShowcasePokemonForm != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcasePokemonForm:%s->%s", FormatNull(p.ShowcasePokemonForm), FormatNull(v))) + } + p.ShowcasePokemonForm = v + p.dirty = true } +} - contest := contestData.ContestIncident.Contests[0] - - pokestopMutex, _ := pokestopStripedMutex.GetLock(fortId) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() - - pokestop, err := GetPokestopRecord(ctx, db, fortId) - if err != nil { - log.Printf("Get pokestop %s", err) - return "Error getting pokestop" +func (p *Pokestop) SetShowcasePokemonType(v null.Int) { + if p.ShowcasePokemonType != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcasePokemonType:%s->%s", FormatNull(p.ShowcasePokemonType), FormatNull(v))) + } + p.ShowcasePokemonType = v + p.dirty = true } +} - if pokestop == nil { - log.Infof("Contest data for pokestop %s not found", fortId) - return fmt.Sprintf("Contest data for pokestop %s not found", fortId) +func (p *Pokestop) SetShowcaseRankingStandard(v null.Int) { + if p.ShowcaseRankingStandard != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcaseRankingStandard:%s->%s", FormatNull(p.ShowcaseRankingStandard), FormatNull(v))) + } + p.ShowcaseRankingStandard = v + p.dirty = true } - - pokestop.updatePokestopFromGetContestDataOutProto(contest) - savePokestopRecord(ctx, db, pokestop) - - return fmt.Sprintf("Contest %s", fortId) } -func getFortIdFromContest(id string) string { - return strings.Split(id, "-")[0] +func (p *Pokestop) SetShowcaseExpiry(v null.Int) { + if p.ShowcaseExpiry != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcaseExpiry:%s->%s", FormatNull(p.ShowcaseExpiry), FormatNull(v))) + } + p.ShowcaseExpiry = v + p.dirty = true + } } -func UpdatePokestopWithPokemonSizeContestEntry(ctx context.Context, db db.DbDetails, request *pogo.GetPokemonSizeLeaderboardEntryProto, contestData *pogo.GetPokemonSizeLeaderboardEntryOutProto) string { - fortId := getFortIdFromContest(request.GetContestId()) - - pokestopMutex, _ := pokestopStripedMutex.GetLock(fortId) - pokestopMutex.Lock() - defer pokestopMutex.Unlock() - - pokestop, err := GetPokestopRecord(ctx, db, fortId) - if err != nil { - log.Printf("Get pokestop %s", err) - return "Error getting pokestop" +func (p *Pokestop) SetShowcaseRankings(v null.String) { + if p.ShowcaseRankings != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("ShowcaseRankings:%s->%s", FormatNull(p.ShowcaseRankings), FormatNull(v))) + } + p.ShowcaseRankings = v + p.dirty = true } +} - if pokestop == nil { - log.Infof("Contest data for pokestop %s not found", fortId) - return fmt.Sprintf("Contest data for pokestop %s not found", fortId) +func (p *Pokestop) SetUpdated(v int64) { + if p.Updated != v { + if dbDebugEnabled { + p.changedFields = append(p.changedFields, fmt.Sprintf("Updated:%d->%d", p.Updated, v)) + } + p.Updated = v + p.dirty = true } - - pokestop.updatePokestopFromGetPokemonSizeContestEntryOutProto(contestData) - savePokestopRecord(ctx, db, pokestop) - - return fmt.Sprintf("Contest Detail %s", fortId) } diff --git a/decoder/pokestop_decode.go b/decoder/pokestop_decode.go new file mode 100644 index 00000000..87941c73 --- /dev/null +++ b/decoder/pokestop_decode.go @@ -0,0 +1,475 @@ +package decoder + +import ( + "encoding/json" + "strings" + "time" + + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/pogo" + "golbat/tz" + "golbat/util" +) + +var LureTime int64 = 1800 + +func (stop *Pokestop) updatePokestopFromFort(fortData *pogo.PokemonFortProto, cellId uint64, now int64) *Pokestop { + stop.SetId(fortData.FortId) + stop.SetLat(fortData.Latitude) + stop.SetLon(fortData.Longitude) + + stop.SetPartnerId(null.NewString(fortData.PartnerId, fortData.PartnerId != "")) + stop.SetSponsorId(null.IntFrom(int64(fortData.Sponsor))) + stop.SetEnabled(null.BoolFrom(fortData.Enabled)) + stop.SetArScanEligible(null.IntFrom(util.BoolToInt[int64](fortData.IsArScanEligible))) + stop.SetPowerUpPoints(null.IntFrom(int64(fortData.PowerUpProgressPoints))) + powerUpLevel, powerUpEndTimestamp := calculatePowerUpPoints(fortData) + stop.SetPowerUpLevel(powerUpLevel) + stop.SetPowerUpEndTimestamp(powerUpEndTimestamp) + + // lasModifiedMs is also modified when incident happens + lastModifiedTimestamp := fortData.LastModifiedMs / 1000 + stop.SetLastModifiedTimestamp(null.IntFrom(lastModifiedTimestamp)) + + if len(fortData.ActiveFortModifier) > 0 { + lureId := int16(fortData.ActiveFortModifier[0]) + if lureId >= 501 && lureId <= 510 { + lureEnd := lastModifiedTimestamp + LureTime + oldLureEnd := stop.LureExpireTimestamp.ValueOrZero() + if stop.LureId != lureId { + stop.SetLureExpireTimestamp(null.IntFrom(lureEnd)) + stop.SetLureId(lureId) + } else { + // wait some time after lure end before a restart in case of timing issue + if now > oldLureEnd+30 { + for now > lureEnd { + lureEnd += LureTime + } + // lure needs to be restarted + stop.SetLureExpireTimestamp(null.IntFrom(lureEnd)) + } + } + } + } + + if fortData.ImageUrl != "" { + stop.SetUrl(null.StringFrom(fortData.ImageUrl)) + } + stop.SetCellId(null.IntFrom(int64(cellId))) + + if stop.Deleted { + stop.SetDeleted(false) + log.Warnf("Cleared Stop with id '%s' is found again in GMO, therefore un-deleted", stop.Id) + // Restore in fort tracker if enabled + if fortTracker != nil { + fortTracker.RestoreFort(stop.Id, cellId, false, time.Now().Unix()) + } + } + return stop +} + +func (stop *Pokestop) updatePokestopFromQuestProto(questProto *pogo.FortSearchOutProto, haveAr bool) string { + + if questProto.ChallengeQuest == nil { + log.Debugf("Received blank quest") + return "Blank quest" + } + questData := questProto.ChallengeQuest.Quest + questTitle := questProto.ChallengeQuest.QuestDisplay.Description + questType := int64(questData.QuestType) + questTarget := int64(questData.Goal.Target) + questTemplate := strings.ToLower(questData.TemplateId) + + conditions := []map[string]any{} + rewards := []map[string]any{} + + for _, conditionData := range questData.Goal.Condition { + condition := make(map[string]any) + infoData := make(map[string]any) + condition["type"] = int(conditionData.Type) + switch conditionData.Type { + case pogo.QuestConditionProto_WITH_BADGE_TYPE: + info := conditionData.GetWithBadgeType() + infoData["amount"] = info.Amount + infoData["badge_rank"] = info.BadgeRank + badgeTypeById := []int{} + for _, badge := range info.BadgeType { + badgeTypeById = append(badgeTypeById, int(badge)) + } + infoData["badge_types"] = badgeTypeById + + case pogo.QuestConditionProto_WITH_ITEM: + info := conditionData.GetWithItem() + if int(info.Item) != 0 { + infoData["item_id"] = int(info.Item) + } + case pogo.QuestConditionProto_WITH_RAID_LEVEL: + info := conditionData.GetWithRaidLevel() + raidLevelById := []int{} + for _, raidLevel := range info.RaidLevel { + raidLevelById = append(raidLevelById, int(raidLevel)) + } + infoData["raid_levels"] = raidLevelById + case pogo.QuestConditionProto_WITH_POKEMON_TYPE: + info := conditionData.GetWithPokemonType() + pokemonTypesById := []int{} + for _, t := range info.PokemonType { + pokemonTypesById = append(pokemonTypesById, int(t)) + } + infoData["pokemon_type_ids"] = pokemonTypesById + case pogo.QuestConditionProto_WITH_POKEMON_CATEGORY: + info := conditionData.GetWithPokemonCategory() + if info.CategoryName != "" { + infoData["category_name"] = info.CategoryName + } + pokemonById := []int{} + for _, pokemon := range info.PokemonIds { + pokemonById = append(pokemonById, int(pokemon)) + } + infoData["pokemon_ids"] = pokemonById + case pogo.QuestConditionProto_WITH_WIN_RAID_STATUS: + case pogo.QuestConditionProto_WITH_THROW_TYPE: + info := conditionData.GetWithThrowType() + if int(info.GetThrowType()) != 0 { // TODO: RDM has ThrowType here, ensure it is the same thing + infoData["throw_type_id"] = int(info.GetThrowType()) + } + infoData["hit"] = info.GetHit() + case pogo.QuestConditionProto_WITH_THROW_TYPE_IN_A_ROW: + info := conditionData.GetWithThrowType() + if int(info.GetThrowType()) != 0 { + infoData["throw_type_id"] = int(info.GetThrowType()) + } + infoData["hit"] = info.GetHit() + case pogo.QuestConditionProto_WITH_LOCATION: + info := conditionData.GetWithLocation() + infoData["cell_ids"] = info.S2CellId + case pogo.QuestConditionProto_WITH_DISTANCE: + info := conditionData.GetWithDistance() + infoData["distance"] = info.DistanceKm + case pogo.QuestConditionProto_WITH_POKEMON_ALIGNMENT: + info := conditionData.GetWithPokemonAlignment() + alignmentIds := []int{} + for _, alignment := range info.Alignment { + alignmentIds = append(alignmentIds, int(alignment)) + } + infoData["alignment_ids"] = alignmentIds + case pogo.QuestConditionProto_WITH_INVASION_CHARACTER: + info := conditionData.GetWithInvasionCharacter() + characterCategoryIds := []int{} + for _, characterCategory := range info.Category { + characterCategoryIds = append(characterCategoryIds, int(characterCategory)) + } + infoData["character_category_ids"] = characterCategoryIds + case pogo.QuestConditionProto_WITH_NPC_COMBAT: + info := conditionData.GetWithNpcCombat() + infoData["win"] = info.RequiresWin + infoData["template_ids"] = info.CombatNpcTrainerId + case pogo.QuestConditionProto_WITH_PLAYER_LEVEL: + info := conditionData.GetWithPlayerLevel() + infoData["level"] = info.Level + case pogo.QuestConditionProto_WITH_BUDDY: + info := conditionData.GetWithBuddy() + if info != nil { + infoData["min_buddy_level"] = int(info.MinBuddyLevel) + infoData["must_be_on_map"] = info.MustBeOnMap + } else { + infoData["min_buddy_level"] = 0 + infoData["must_be_on_map"] = false + } + case pogo.QuestConditionProto_WITH_DAILY_BUDDY_AFFECTION: + info := conditionData.GetWithDailyBuddyAffection() + infoData["min_buddy_affection_earned_today"] = info.MinBuddyAffectionEarnedToday + case pogo.QuestConditionProto_WITH_TEMP_EVO_POKEMON: + info := conditionData.GetWithTempEvoId() + tempEvoIds := []int{} + for _, evolution := range info.MegaForm { + tempEvoIds = append(tempEvoIds, int(evolution)) + } + infoData["raid_pokemon_evolutions"] = tempEvoIds + case pogo.QuestConditionProto_WITH_ITEM_TYPE: + info := conditionData.GetWithItemType() + itemTypes := []int{} + for _, itemType := range info.ItemType { + itemTypes = append(itemTypes, int(itemType)) + } + infoData["item_type_ids"] = itemTypes + case pogo.QuestConditionProto_WITH_RAID_ELAPSED_TIME: + info := conditionData.GetWithElapsedTime() + infoData["time"] = int64(info.ElapsedTimeMs) / 1000 + case pogo.QuestConditionProto_WITH_WIN_GYM_BATTLE_STATUS: + case pogo.QuestConditionProto_WITH_SUPER_EFFECTIVE_CHARGE: + case pogo.QuestConditionProto_WITH_UNIQUE_POKESTOP: + case pogo.QuestConditionProto_WITH_QUEST_CONTEXT: + case pogo.QuestConditionProto_WITH_WIN_BATTLE_STATUS: + case pogo.QuestConditionProto_WITH_CURVE_BALL: + case pogo.QuestConditionProto_WITH_NEW_FRIEND: + case pogo.QuestConditionProto_WITH_DAYS_IN_A_ROW: + case pogo.QuestConditionProto_WITH_WEATHER_BOOST: + case pogo.QuestConditionProto_WITH_DAILY_CAPTURE_BONUS: + case pogo.QuestConditionProto_WITH_DAILY_SPIN_BONUS: + case pogo.QuestConditionProto_WITH_UNIQUE_POKEMON: + case pogo.QuestConditionProto_WITH_BUDDY_INTERESTING_POI: + case pogo.QuestConditionProto_WITH_POKEMON_LEVEL: + case pogo.QuestConditionProto_WITH_SINGLE_DAY: + case pogo.QuestConditionProto_WITH_UNIQUE_POKEMON_TEAM: + case pogo.QuestConditionProto_WITH_MAX_CP: + case pogo.QuestConditionProto_WITH_LUCKY_POKEMON: + case pogo.QuestConditionProto_WITH_LEGENDARY_POKEMON: + case pogo.QuestConditionProto_WITH_GBL_RANK: + case pogo.QuestConditionProto_WITH_CATCHES_IN_A_ROW: + case pogo.QuestConditionProto_WITH_ENCOUNTER_TYPE: + case pogo.QuestConditionProto_WITH_COMBAT_TYPE: + case pogo.QuestConditionProto_WITH_GEOTARGETED_POI: + case pogo.QuestConditionProto_WITH_FRIEND_LEVEL: + case pogo.QuestConditionProto_WITH_STICKER: + case pogo.QuestConditionProto_WITH_POKEMON_CP: + case pogo.QuestConditionProto_WITH_RAID_LOCATION: + case pogo.QuestConditionProto_WITH_FRIENDS_RAID: + case pogo.QuestConditionProto_WITH_POKEMON_COSTUME: + default: + break + } + + if infoData != nil { + condition["info"] = infoData + } + conditions = append(conditions, condition) + } + + for _, rewardData := range questData.QuestRewards { + reward := make(map[string]any) + infoData := make(map[string]any) + reward["type"] = int(rewardData.Type) + switch rewardData.Type { + case pogo.QuestRewardProto_EXPERIENCE: + infoData["amount"] = rewardData.GetExp() + case pogo.QuestRewardProto_ITEM: + info := rewardData.GetItem() + infoData["amount"] = info.Amount + infoData["item_id"] = int(info.Item) + case pogo.QuestRewardProto_STARDUST: + infoData["amount"] = rewardData.GetStardust() + case pogo.QuestRewardProto_CANDY: + info := rewardData.GetCandy() + infoData["amount"] = info.Amount + infoData["pokemon_id"] = int(info.PokemonId) + case pogo.QuestRewardProto_XL_CANDY: + info := rewardData.GetXlCandy() + infoData["amount"] = info.Amount + infoData["pokemon_id"] = int(info.PokemonId) + case pogo.QuestRewardProto_POKEMON_ENCOUNTER: + info := rewardData.GetPokemonEncounter() + if info.IsHiddenDitto { + infoData["pokemon_id"] = 132 + infoData["pokemon_id_display"] = int(info.GetPokemonId()) + } else { + infoData["pokemon_id"] = int(info.GetPokemonId()) + } + if info.ShinyProbability > 0.0 { + infoData["shiny_probability"] = info.ShinyProbability + } + if display := info.PokemonDisplay; display != nil { + if costumeId := int(display.Costume); costumeId != 0 { + infoData["costume_id"] = costumeId + } + if formId := int(display.Form); formId != 0 { + infoData["form_id"] = formId + } + if genderId := int(display.Gender); genderId != 0 { + infoData["gender_id"] = genderId + } + if display.Shiny { + infoData["shiny"] = display.Shiny + } + if background := util.ExtractBackgroundFromDisplay(display); background != nil { + infoData["background"] = background + } + if breadMode := int(display.BreadModeEnum); breadMode != 0 { + infoData["bread_mode"] = breadMode + } + } else { + + } + case pogo.QuestRewardProto_POKECOIN: + infoData["amount"] = rewardData.GetPokecoin() + case pogo.QuestRewardProto_STICKER: + info := rewardData.GetSticker() + infoData["amount"] = info.Amount + infoData["sticker_id"] = info.StickerId + case pogo.QuestRewardProto_MEGA_RESOURCE: + info := rewardData.GetMegaResource() + infoData["amount"] = info.Amount + infoData["pokemon_id"] = int(info.PokemonId) + case pogo.QuestRewardProto_AVATAR_CLOTHING: + case pogo.QuestRewardProto_QUEST: + case pogo.QuestRewardProto_LEVEL_CAP: + case pogo.QuestRewardProto_INCIDENT: + case pogo.QuestRewardProto_PLAYER_ATTRIBUTE: + default: + break + + } + reward["info"] = infoData + rewards = append(rewards, reward) + } + + questConditions, _ := json.Marshal(conditions) + questRewards, _ := json.Marshal(rewards) + questTimestamp := time.Now().Unix() + + questExpiry := null.NewInt(0, false) + + stopTimezone := tz.SearchTimezone(stop.Lat, stop.Lon) + if stopTimezone != "" { + loc, err := time.LoadLocation(stopTimezone) + if err != nil { + log.Warnf("Unrecognised time zone %s at %f,%f", stopTimezone, stop.Lat, stop.Lon) + } else { + year, month, day := time.Now().In(loc).Date() + t := time.Date(year, month, day, 0, 0, 0, 0, loc).AddDate(0, 0, 1) + unixTime := t.Unix() + questExpiry = null.IntFrom(unixTime) + } + } + + if questExpiry.Valid == false { + questExpiry = null.IntFrom(time.Now().Unix() + 24*60*60) // Set expiry to 24 hours from now + } + + if !haveAr { + stop.SetAlternativeQuestType(null.IntFrom(questType)) + stop.SetAlternativeQuestTarget(null.IntFrom(questTarget)) + stop.SetAlternativeQuestTemplate(null.StringFrom(questTemplate)) + stop.SetAlternativeQuestTitle(null.StringFrom(questTitle)) + stop.SetAlternativeQuestConditions(null.StringFrom(string(questConditions))) + stop.SetAlternativeQuestRewards(null.StringFrom(string(questRewards))) + stop.SetAlternativeQuestTimestamp(null.IntFrom(questTimestamp)) + stop.SetAlternativeQuestExpiry(questExpiry) + } else { + stop.SetQuestType(null.IntFrom(questType)) + stop.SetQuestTarget(null.IntFrom(questTarget)) + stop.SetQuestTemplate(null.StringFrom(questTemplate)) + stop.SetQuestTitle(null.StringFrom(questTitle)) + stop.SetQuestConditions(null.StringFrom(string(questConditions))) + stop.SetQuestRewards(null.StringFrom(string(questRewards))) + stop.SetQuestTimestamp(null.IntFrom(questTimestamp)) + stop.SetQuestExpiry(questExpiry) + } + + return questTitle +} + +func (stop *Pokestop) updatePokestopFromFortDetailsProto(fortData *pogo.FortDetailsOutProto) *Pokestop { + stop.SetId(fortData.Id) + stop.SetLat(fortData.Latitude) + stop.SetLon(fortData.Longitude) + if len(fortData.ImageUrl) > 0 { + stop.SetUrl(null.StringFrom(fortData.ImageUrl[0])) + } + stop.SetName(null.StringFrom(fortData.Name)) + + if fortData.Description == "" { + stop.SetDescription(null.NewString("", false)) + } else { + stop.SetDescription(null.StringFrom(fortData.Description)) + } + + if fortData.Modifier != nil && len(fortData.Modifier) > 0 { + // DeployingPlayerCodename contains the name of the player if we want that + lureId := int16(fortData.Modifier[0].ModifierType) + lureExpiry := fortData.Modifier[0].ExpirationTimeMs / 1000 + + stop.SetLureId(lureId) + stop.SetLureExpireTimestamp(null.IntFrom(lureExpiry)) + } + + return stop +} + +func (stop *Pokestop) updatePokestopFromGetMapFortsOutProto(fortData *pogo.GetMapFortsOutProto_FortProto) *Pokestop { + stop.SetId(fortData.Id) + stop.SetLat(fortData.Latitude) + stop.SetLon(fortData.Longitude) + + if len(fortData.Image) > 0 { + stop.SetUrl(null.StringFrom(fortData.Image[0].Url)) + } + stop.SetName(null.StringFrom(fortData.Name)) + if stop.Deleted { + log.Debugf("Cleared Stop with id '%s' is found again in GMF, therefore kept deleted", stop.Id) + } + return stop +} + +func (stop *Pokestop) updatePokestopFromGetContestDataOutProto(contest *pogo.ContestProto) { + stop.SetShowcaseRankingStandard(null.IntFrom(int64(contest.GetMetric().GetRankingStandard()))) + stop.SetShowcaseExpiry(null.IntFrom(contest.GetSchedule().GetContestCycle().GetEndTimeMs() / 1000)) + + focusStore := createFocusStoreFromContestProto(contest) + + if len(focusStore) > 1 { + log.Warnf("SHOWCASE: we got more than one showcase focus: %v", focusStore) + } + + for key, focus := range focusStore { + focus["type"] = key + jsonBytes, err := json.Marshal(focus) + if err != nil { + log.Errorf("SHOWCASE: Stop '%s' - Focus '%v' marshalling failed: %s", stop.Id, focus, err) + } + stop.SetShowcaseFocus(null.StringFrom(string(jsonBytes))) + // still support old format - probably still required to filter in external tools + stop.extractShowcasePokemonInfoDeprecated(key, focus) + } +} + +func (stop *Pokestop) updatePokestopFromGetPokemonSizeContestEntryOutProto(contestData *pogo.GetPokemonSizeLeaderboardEntryOutProto) { + type contestEntry struct { + Rank int `json:"rank"` + Score float64 `json:"score"` + PokemonId int `json:"pokemon_id"` + Form int `json:"form"` + Costume int `json:"costume"` + Gender int `json:"gender"` + Shiny bool `json:"shiny"` + TempEvolution int `json:"temp_evolution"` + TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms"` + Alignment int `json:"alignment"` + Badge int `json:"badge"` + Background *int64 `json:"background,omitempty"` + } + type contestJson struct { + TotalEntries int `json:"total_entries"` + LastUpdate int64 `json:"last_update"` + ContestEntries []contestEntry `json:"contest_entries"` + } + + j := contestJson{LastUpdate: time.Now().Unix()} + j.TotalEntries = int(contestData.TotalEntries) + + for _, entry := range contestData.GetContestEntries() { + rank := entry.GetRank() + if rank > 3 { + break + } + j.ContestEntries = append(j.ContestEntries, contestEntry{ + Rank: int(rank), + Score: entry.GetScore(), + PokemonId: int(entry.GetPokedexId()), + Form: int(entry.GetPokemonDisplay().Form), + Costume: int(entry.GetPokemonDisplay().Costume), + Gender: int(entry.GetPokemonDisplay().Gender), + Shiny: entry.GetPokemonDisplay().Shiny, + TempEvolution: int(entry.GetPokemonDisplay().CurrentTempEvolution), + TempEvolutionFinishMs: entry.GetPokemonDisplay().TemporaryEvolutionFinishMs, + Alignment: int(entry.GetPokemonDisplay().Alignment), + Badge: int(entry.GetPokemonDisplay().PokemonBadge), + Background: util.ExtractBackgroundFromDisplay(entry.PokemonDisplay), + }) + + } + jsonString, _ := json.Marshal(j) + stop.SetShowcaseRankings(null.StringFrom(string(jsonString))) +} diff --git a/decoder/pokestop_process.go b/decoder/pokestop_process.go new file mode 100644 index 00000000..69dd78da --- /dev/null +++ b/decoder/pokestop_process.go @@ -0,0 +1,167 @@ +package decoder + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/paulmach/orb/geojson" + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func UpdatePokestopRecordWithFortDetailsOutProto(ctx context.Context, db db.DbDetails, fort *pogo.FortDetailsOutProto) string { + pokestop, unlock, err := getOrCreatePokestopRecord(ctx, db, fort.Id) + if err != nil { + log.Printf("Update pokestop %s", err) + return fmt.Sprintf("Error %s", err) + } + defer unlock() + + pokestop.updatePokestopFromFortDetailsProto(fort) + + updatePokestopGetMapFortCache(pokestop) + savePokestopRecord(ctx, db, pokestop) + return fmt.Sprintf("%s %s", fort.Id, fort.Name) +} + +func UpdatePokestopWithQuest(ctx context.Context, db db.DbDetails, quest *pogo.FortSearchOutProto, haveAr bool) string { + haveArStr := "NoAR" + if haveAr { + haveArStr = "AR" + } + + if quest.ChallengeQuest == nil { + statsCollector.IncDecodeQuest("error", "no_quest") + return fmt.Sprintf("%s %s Blank quest", quest.FortId, haveArStr) + } + + statsCollector.IncDecodeQuest("ok", haveArStr) + + pokestop, unlock, err := getOrCreatePokestopRecord(ctx, db, quest.FortId) + if err != nil { + log.Printf("Update quest %s", err) + return fmt.Sprintf("error %s", err) + } + defer unlock() + + questTitle := pokestop.updatePokestopFromQuestProto(quest, haveAr) + + updatePokestopGetMapFortCache(pokestop) + savePokestopRecord(ctx, db, pokestop) + + areas := MatchStatsGeofence(pokestop.Lat, pokestop.Lon) + updateQuestStats(pokestop, haveAr, areas) + + return fmt.Sprintf("%s %s %s", quest.FortId, haveArStr, questTitle) +} + +func ClearQuestsWithinGeofence(ctx context.Context, dbDetails db.DbDetails, geofence *geojson.Feature) { + started := time.Now() + rows, err := db.RemoveQuests(ctx, dbDetails, geofence) + if err != nil { + log.Errorf("ClearQuest: Error removing quests: %s", err) + return + } + ClearPokestopCache() + log.Infof("ClearQuest: Removed quests from %d pokestops in %s", rows, time.Since(started)) +} + +func GetQuestStatusWithGeofence(dbDetails db.DbDetails, geofence *geojson.Feature) db.QuestStatus { + res, err := db.GetQuestStatus(dbDetails, geofence) + if err != nil { + log.Errorf("QuestStatus: Error retrieving quests: %s", err) + return db.QuestStatus{} + } + return res +} + +func UpdatePokestopRecordWithGetMapFortsOutProto(ctx context.Context, db db.DbDetails, mapFort *pogo.GetMapFortsOutProto_FortProto) (bool, string) { + pokestop, unlock, err := getPokestopRecordForUpdate(ctx, db, mapFort.Id) + if err != nil { + log.Printf("Update pokestop %s", err) + return false, fmt.Sprintf("Error %s", err) + } + + if pokestop == nil { + return false, "" + } + defer unlock() + + pokestop.updatePokestopFromGetMapFortsOutProto(mapFort) + savePokestopRecord(ctx, db, pokestop) + return true, fmt.Sprintf("%s %s", mapFort.Id, mapFort.Name) +} + +func GetPokestopPositions(details db.DbDetails, geofence *geojson.Feature) ([]db.QuestLocation, error) { + return db.GetPokestopPositions(details, geofence) +} + +func UpdatePokestopWithContestData(ctx context.Context, db db.DbDetails, request *pogo.GetContestDataProto, contestData *pogo.GetContestDataOutProto) string { + if contestData.ContestIncident == nil || len(contestData.ContestIncident.Contests) == 0 { + return "No contests found" + } + + var fortId string + if request != nil { + fortId = request.FortId + } else { + fortId = getFortIdFromContest(contestData.ContestIncident.Contests[0].ContestId) + } + + if fortId == "" { + return "No fortId found" + } + + if len(contestData.ContestIncident.Contests) > 1 { + log.Errorf("More than one contest found") + return fmt.Sprintf("More than one contest found in %s", fortId) + } + + contest := contestData.ContestIncident.Contests[0] + + pokestop, unlock, err := getPokestopRecordForUpdate(ctx, db, fortId) + if err != nil { + log.Printf("Get pokestop %s", err) + return "Error getting pokestop" + } + + if pokestop == nil { + log.Infof("Contest data for pokestop %s not found", fortId) + return fmt.Sprintf("Contest data for pokestop %s not found", fortId) + } + defer unlock() + + pokestop.updatePokestopFromGetContestDataOutProto(contest) + savePokestopRecord(ctx, db, pokestop) + + return fmt.Sprintf("Contest %s", fortId) +} + +func getFortIdFromContest(id string) string { + return strings.Split(id, "-")[0] +} + +func UpdatePokestopWithPokemonSizeContestEntry(ctx context.Context, db db.DbDetails, request *pogo.GetPokemonSizeLeaderboardEntryProto, contestData *pogo.GetPokemonSizeLeaderboardEntryOutProto) string { + fortId := getFortIdFromContest(request.GetContestId()) + + pokestop, unlock, err := getPokestopRecordForUpdate(ctx, db, fortId) + if err != nil { + log.Printf("Get pokestop %s", err) + return "Error getting pokestop" + } + + if pokestop == nil { + log.Infof("Contest data for pokestop %s not found", fortId) + return fmt.Sprintf("Contest data for pokestop %s not found", fortId) + } + defer unlock() + + pokestop.updatePokestopFromGetPokemonSizeContestEntryOutProto(contestData) + savePokestopRecord(ctx, db, pokestop) + + return fmt.Sprintf("Contest Detail %s", fortId) +} diff --git a/decoder/pokestop_showcase.go b/decoder/pokestop_showcase.go index bf9de8a2..44fba2ba 100644 --- a/decoder/pokestop_showcase.go +++ b/decoder/pokestop_showcase.go @@ -3,8 +3,8 @@ package decoder import ( "golbat/pogo" + "github.com/guregu/null/v6" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) type contestFocusType string @@ -95,28 +95,28 @@ func createFocusStoreFromContestProto(contest *pogo.ContestProto) map[contestFoc func (stop *Pokestop) extractShowcasePokemonInfoDeprecated(key contestFocusType, focus map[string]any) { if key == focusPokemon { if pokemonID, ok := focus["pokemon_id"].(int32); ok { - stop.ShowcasePokemon = null.IntFrom(int64(pokemonID)) + stop.SetShowcasePokemon(null.IntFrom(int64(pokemonID))) } else { log.Warnf("SHOWCASE: Stop '%s' - Missing or invalid 'pokemon_id'", stop.Id) - stop.ShowcasePokemon = null.IntFromPtr(nil) + stop.SetShowcasePokemon(null.IntFromPtr(nil)) } if form, ok := focus["pokemon_form"].(int32); ok { - stop.ShowcasePokemonForm = null.IntFrom(int64(form)) + stop.SetShowcasePokemonForm(null.IntFrom(int64(form))) } else { - stop.ShowcasePokemonForm = null.IntFromPtr(nil) + stop.SetShowcasePokemonForm(null.IntFromPtr(nil)) } } else { - stop.ShowcasePokemon = null.IntFromPtr(nil) - stop.ShowcasePokemonForm = null.IntFromPtr(nil) + stop.SetShowcasePokemon(null.IntFromPtr(nil)) + stop.SetShowcasePokemonForm(null.IntFromPtr(nil)) } if key == focusPokemonType { if type1, ok := focus["pokemon_type_1"].(int32); ok { - stop.ShowcasePokemonType = null.IntFrom(int64(type1)) + stop.SetShowcasePokemonType(null.IntFrom(int64(type1))) } else { log.Warnf("SHOWCASE: Stop '%s' - Missing or invalid 'pokemon_type_1'", stop.Id) - stop.ShowcasePokemonType = null.IntFromPtr(nil) + stop.SetShowcasePokemonType(null.IntFromPtr(nil)) } if type2, ok := focus["pokemon_type_2"].(int32); ok { @@ -125,6 +125,6 @@ func (stop *Pokestop) extractShowcasePokemonInfoDeprecated(key contestFocusType, } } } else { - stop.ShowcasePokemonType = null.IntFromPtr(nil) + stop.SetShowcasePokemonType(null.IntFromPtr(nil)) } } diff --git a/decoder/pokestop_state.go b/decoder/pokestop_state.go new file mode 100644 index 00000000..9fced113 --- /dev/null +++ b/decoder/pokestop_state.go @@ -0,0 +1,397 @@ +package decoder + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/guregu/null/v6" + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" + + "golbat/config" + "golbat/db" + "golbat/webhooks" +) + +func loadPokestopFromDatabase(ctx context.Context, db db.DbDetails, fortId string, pokestop *Pokestop) error { + err := db.GeneralDb.GetContext(ctx, pokestop, + `SELECT pokestop.id, lat, lon, name, url, enabled, lure_expire_timestamp, last_modified_timestamp, + pokestop.updated, quest_type, quest_timestamp, quest_target, quest_conditions, + quest_rewards, quest_template, quest_title, + alternative_quest_type, alternative_quest_timestamp, alternative_quest_target, + alternative_quest_conditions, alternative_quest_rewards, + alternative_quest_template, alternative_quest_title, cell_id, deleted, lure_id, sponsor_id, partner_id, + ar_scan_eligible, power_up_points, power_up_level, power_up_end_timestamp, + quest_expiry, alternative_quest_expiry, description, showcase_pokemon_id, showcase_pokemon_form_id, + showcase_pokemon_type_id, showcase_ranking_standard, showcase_expiry, showcase_rankings + FROM pokestop + WHERE pokestop.id = ? `, fortId) + statsCollector.IncDbQuery("select pokestop", err) + return err +} + +// PeekPokestopRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func PeekPokestopRecord(fortId string) (*Pokestop, func(), error) { + if item := pokestopCache.Get(fortId); item != nil { + pokestop := item.Value() + pokestop.Lock() + return pokestop, func() { pokestop.Unlock() }, nil + } + return nil, nil, nil +} + +// getPokestopRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getPokestopRecordReadOnly(ctx context.Context, db db.DbDetails, fortId string) (*Pokestop, func(), error) { + // Check cache first + if item := pokestopCache.Get(fortId); item != nil { + pokestop := item.Value() + pokestop.Lock() + return pokestop, func() { pokestop.Unlock() }, nil + } + + dbPokestop := Pokestop{} + err := loadPokestopFromDatabase(ctx, db, fortId, &dbPokestop) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + + dbPokestop.ClearDirty() + + // Atomically cache the loaded Pokestop - if another goroutine raced us, + // we'll get their Pokestop and use that instead (ensuring same mutex) + existingPokestop, _ := pokestopCache.GetOrSetFunc(fortId, func() *Pokestop { + // Only called if key doesn't exist - our Pokestop wins + if config.Config.TestFortInMemory { + fortRtreeUpdatePokestopOnGet(&dbPokestop) + } + return &dbPokestop + }) + + pokestop := existingPokestop.Value() + pokestop.Lock() + return pokestop, func() { pokestop.Unlock() }, nil +} + +// getPokestopRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Use when modifying the Pokestop. +// Caller MUST call returned unlock function if non-nil. +func getPokestopRecordForUpdate(ctx context.Context, db db.DbDetails, fortId string) (*Pokestop, func(), error) { + pokestop, unlock, err := getPokestopRecordReadOnly(ctx, db, fortId) + if err != nil || pokestop == nil { + return nil, nil, err + } + pokestop.snapshotOldValues() + return pokestop, unlock, nil +} + +// getOrCreatePokestopRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreatePokestopRecord(ctx context.Context, db db.DbDetails, fortId string) (*Pokestop, func(), error) { + // Create new Pokestop atomically - function only called if key doesn't exist + pokestopItem, _ := pokestopCache.GetOrSetFunc(fortId, func() *Pokestop { + return &Pokestop{Id: fortId, newRecord: true} + }) + + pokestop := pokestopItem.Value() + pokestop.Lock() + + if pokestop.newRecord { + // We should attempt to load from database + err := loadPokestopFromDatabase(ctx, db, fortId, pokestop) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + pokestop.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + pokestop.newRecord = false + pokestop.ClearDirty() + if config.Config.TestFortInMemory { + fortRtreeUpdatePokestopOnGet(pokestop) + } + } + } + + pokestop.snapshotOldValues() + return pokestop, func() { pokestop.Unlock() }, nil +} + +type QuestWebhook struct { + PokestopId string `json:"pokestop_id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + PokestopName string `json:"pokestop_name"` + Type null.Int `json:"type"` + Target null.Int `json:"target"` + Template null.String `json:"template"` + Title null.String `json:"title"` + Conditions json.RawMessage `json:"conditions"` + Rewards json.RawMessage `json:"rewards"` + Updated int64 `json:"updated"` + ArScanEligible int64 `json:"ar_scan_eligible"` + PokestopUrl string `json:"pokestop_url"` + WithAr bool `json:"with_ar"` +} + +type PokestopWebhook struct { + PokestopId string `json:"pokestop_id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` + Url string `json:"url"` + LureExpiration int64 `json:"lure_expiration"` + LastModified int64 `json:"last_modified"` + Enabled bool `json:"enabled"` + LureId int16 `json:"lure_id"` + ArScanEligible int64 `json:"ar_scan_eligible"` + PowerUpLevel int64 `json:"power_up_level"` + PowerUpPoints int64 `json:"power_up_points"` + PowerUpEndTimestamp int64 `json:"power_up_end_timestamp"` + Updated int64 `json:"updated"` + ShowcaseFocus null.String `json:"showcase_focus"` + ShowcasePokemonId null.Int `json:"showcase_pokemon_id"` + ShowcasePokemonFormId null.Int `json:"showcase_pokemon_form_id"` + ShowcasePokemonTypeId null.Int `json:"showcase_pokemon_type_id"` + ShowcaseRankingStandard null.Int `json:"showcase_ranking_standard"` + ShowcaseExpiry null.Int `json:"showcase_expiry"` + ShowcaseRankings json.RawMessage `json:"showcase_rankings"` +} + +func createPokestopFortWebhooks(stop *Pokestop) { + fort := InitWebHookFortFromPokestop(stop) + if stop.newRecord { + CreateFortWebHooks(nil, fort, NEW) + } else { + // Build old fort from saved old values + oldFort := &FortWebhook{ + Type: POKESTOP.String(), + Id: stop.Id, + Name: stop.oldValues.Name.Ptr(), + ImageUrl: stop.oldValues.Url.Ptr(), + Description: stop.oldValues.Description.Ptr(), + Location: Location{Latitude: stop.oldValues.Lat, Longitude: stop.oldValues.Lon}, + } + CreateFortWebHooks(oldFort, fort, EDIT) + } +} + +func createPokestopWebhooks(stop *Pokestop) { + + areas := MatchStatsGeofence(stop.Lat, stop.Lon) + + pokestopName := "Unknown" + if stop.Name.Valid { + pokestopName = stop.Name.String + } + + if stop.AlternativeQuestType.Valid && (stop.newRecord || stop.AlternativeQuestType != stop.oldValues.AlternativeQuestType) { + questHook := QuestWebhook{ + PokestopId: stop.Id, + Latitude: stop.Lat, + Longitude: stop.Lon, + PokestopName: pokestopName, + Type: stop.AlternativeQuestType, + Target: stop.AlternativeQuestTarget, + Template: stop.AlternativeQuestTemplate, + Title: stop.AlternativeQuestTitle, + Conditions: json.RawMessage(stop.AlternativeQuestConditions.ValueOrZero()), + Rewards: json.RawMessage(stop.AlternativeQuestRewards.ValueOrZero()), + Updated: stop.Updated, + ArScanEligible: stop.ArScanEligible.ValueOrZero(), + PokestopUrl: stop.Url.ValueOrZero(), + WithAr: false, + } + webhooksSender.AddMessage(webhooks.Quest, questHook, areas) + } + + if stop.QuestType.Valid && (stop.newRecord || stop.QuestType != stop.oldValues.QuestType) { + questHook := QuestWebhook{ + PokestopId: stop.Id, + Latitude: stop.Lat, + Longitude: stop.Lon, + PokestopName: pokestopName, + Type: stop.QuestType, + Target: stop.QuestTarget, + Template: stop.QuestTemplate, + Title: stop.QuestTitle, + Conditions: json.RawMessage(stop.QuestConditions.ValueOrZero()), + Rewards: json.RawMessage(stop.QuestRewards.ValueOrZero()), + Updated: stop.Updated, + ArScanEligible: stop.ArScanEligible.ValueOrZero(), + PokestopUrl: stop.Url.ValueOrZero(), + WithAr: true, + } + webhooksSender.AddMessage(webhooks.Quest, questHook, areas) + } + if (stop.newRecord && (stop.LureId != 0 || stop.PowerUpEndTimestamp.ValueOrZero() != 0)) || (!stop.newRecord && ((stop.LureExpireTimestamp != stop.oldValues.LureExpireTimestamp && stop.LureId != 0) || stop.PowerUpEndTimestamp != stop.oldValues.PowerUpEndTimestamp)) { + var showcaseRankings json.RawMessage + if stop.ShowcaseRankings.Valid { + showcaseRankings = json.RawMessage(stop.ShowcaseRankings.ValueOrZero()) + } + + pokestopHook := PokestopWebhook{ + PokestopId: stop.Id, + Latitude: stop.Lat, + Longitude: stop.Lon, + Name: pokestopName, + Url: stop.Url.ValueOrZero(), + LureExpiration: stop.LureExpireTimestamp.ValueOrZero(), + LastModified: stop.LastModifiedTimestamp.ValueOrZero(), + Enabled: stop.Enabled.ValueOrZero(), + LureId: stop.LureId, + ArScanEligible: stop.ArScanEligible.ValueOrZero(), + PowerUpLevel: stop.PowerUpLevel.ValueOrZero(), + PowerUpPoints: stop.PowerUpPoints.ValueOrZero(), + PowerUpEndTimestamp: stop.PowerUpEndTimestamp.ValueOrZero(), + Updated: stop.Updated, + ShowcaseFocus: stop.ShowcaseFocus, + ShowcasePokemonId: stop.ShowcasePokemon, + ShowcasePokemonFormId: stop.ShowcasePokemonForm, + ShowcasePokemonTypeId: stop.ShowcasePokemonType, + ShowcaseRankingStandard: stop.ShowcaseRankingStandard, + ShowcaseExpiry: stop.ShowcaseExpiry, + ShowcaseRankings: showcaseRankings, + } + + webhooksSender.AddMessage(webhooks.Pokestop, pokestopHook, areas) + } +} + +func savePokestopRecord(ctx context.Context, db db.DbDetails, pokestop *Pokestop) { + now := time.Now().Unix() + if !pokestop.IsNewRecord() && !pokestop.IsDirty() { + // default debounce is 15 minutes (900s). If reduce_updates is enabled, use 12 hours. + if pokestop.Updated > now-GetUpdateThreshold(900) { + // if a pokestop is unchanged, but we did see it again after 15 minutes, then save again + return + } + } + pokestop.SetUpdated(now) + + if pokestop.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Pokestop", pokestop.Id, pokestop.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, ` + INSERT INTO pokestop ( + id, lat, lon, name, url, enabled, lure_expire_timestamp, last_modified_timestamp, quest_type, + quest_timestamp, quest_target, quest_conditions, quest_rewards, quest_template, quest_title, + alternative_quest_type, alternative_quest_timestamp, alternative_quest_target, + alternative_quest_conditions, alternative_quest_rewards, alternative_quest_template, + alternative_quest_title, cell_id, lure_id, sponsor_id, partner_id, ar_scan_eligible, + power_up_points, power_up_level, power_up_end_timestamp, updated, first_seen_timestamp, + quest_expiry, alternative_quest_expiry, description, showcase_focus, showcase_pokemon_id, + showcase_pokemon_form_id, showcase_pokemon_type_id, showcase_ranking_standard, showcase_expiry, showcase_rankings + ) + VALUES ( + :id, :lat, :lon, :name, :url, :enabled, :lure_expire_timestamp, :last_modified_timestamp, :quest_type, + :quest_timestamp, :quest_target, :quest_conditions, :quest_rewards, :quest_template, :quest_title, + :alternative_quest_type, :alternative_quest_timestamp, :alternative_quest_target, + :alternative_quest_conditions, :alternative_quest_rewards, :alternative_quest_template, + :alternative_quest_title, :cell_id, :lure_id, :sponsor_id, :partner_id, :ar_scan_eligible, + :power_up_points, :power_up_level, :power_up_end_timestamp, + UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), + :quest_expiry, :alternative_quest_expiry, :description, :showcase_focus, :showcase_pokemon_id, + :showcase_pokemon_form_id, :showcase_pokemon_type_id, :showcase_ranking_standard, :showcase_expiry, :showcase_rankings)`, + pokestop) + + statsCollector.IncDbQuery("insert pokestop", err) + //log.Debugf("Insert pokestop %s %+v", pokestop.Id, pokestop) + if err != nil { + log.Errorf("insert pokestop: %s", err) + return + } + + _, _ = res, err + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Pokestop", pokestop.Id, pokestop.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, ` + UPDATE pokestop SET + lat = :lat, + lon = :lon, + name = :name, + url = :url, + enabled = :enabled, + lure_expire_timestamp = :lure_expire_timestamp, + last_modified_timestamp = :last_modified_timestamp, + updated = :updated, + quest_type = :quest_type, + quest_timestamp = :quest_timestamp, + quest_target = :quest_target, + quest_conditions = :quest_conditions, + quest_rewards = :quest_rewards, + quest_template = :quest_template, + quest_title = :quest_title, + alternative_quest_type = :alternative_quest_type, + alternative_quest_timestamp = :alternative_quest_timestamp, + alternative_quest_target = :alternative_quest_target, + alternative_quest_conditions = :alternative_quest_conditions, + alternative_quest_rewards = :alternative_quest_rewards, + alternative_quest_template = :alternative_quest_template, + alternative_quest_title = :alternative_quest_title, + cell_id = :cell_id, + lure_id = :lure_id, + deleted = :deleted, + sponsor_id = :sponsor_id, + partner_id = :partner_id, + ar_scan_eligible = :ar_scan_eligible, + power_up_points = :power_up_points, + power_up_level = :power_up_level, + power_up_end_timestamp = :power_up_end_timestamp, + quest_expiry = :quest_expiry, + alternative_quest_expiry = :alternative_quest_expiry, + description = :description, + showcase_focus = :showcase_focus, + showcase_pokemon_id = :showcase_pokemon_id, + showcase_pokemon_form_id = :showcase_pokemon_form_id, + showcase_pokemon_type_id = :showcase_pokemon_type_id, + showcase_ranking_standard = :showcase_ranking_standard, + showcase_expiry = :showcase_expiry, + showcase_rankings = :showcase_rankings + WHERE id = :id`, + pokestop, + ) + statsCollector.IncDbQuery("update pokestop", err) + //log.Debugf("Update pokestop %s %+v", pokestop.Id, pokestop) + if err != nil { + log.Errorf("update pokestop %s: %s", pokestop.Id, err) + return + } + _ = res + } + //pokestopCache.Set(pokestop.Id, pokestop, ttlcache.DefaultTTL) + if dbDebugEnabled { + pokestop.changedFields = pokestop.changedFields[:0] + } + + createPokestopWebhooks(pokestop) + createPokestopFortWebhooks(pokestop) + if pokestop.IsNewRecord() { + pokestopCache.Set(pokestop.Id, pokestop, ttlcache.DefaultTTL) + pokestop.newRecord = false + } + pokestop.ClearDirty() + +} + +func updatePokestopGetMapFortCache(pokestop *Pokestop) { + storedGetMapFort := getMapFortsCache.Get(pokestop.Id) + if storedGetMapFort != nil { + getMapFort := storedGetMapFort.Value() + getMapFortsCache.Delete(pokestop.Id) + pokestop.updatePokestopFromGetMapFortsOutProto(getMapFort) + log.Debugf("Updated Gym using stored getMapFort: %s", pokestop.Id) + } +} diff --git a/decoder/routes.go b/decoder/routes.go index 892413ac..567a7adb 100644 --- a/decoder/routes.go +++ b/decoder/routes.go @@ -1,20 +1,17 @@ package decoder import ( - "database/sql" - "encoding/json" "fmt" - "golbat/db" - "golbat/pogo" - "golbat/util" - "time" - - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" + "sync" + + "github.com/guregu/null/v6" ) +// Route struct. +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Route struct { + mu sync.Mutex `db:"-" json:"-"` // Object-level mutex + Id string `db:"id"` Name string `db:"name"` Shortcode string `db:"shortcode"` @@ -37,192 +34,260 @@ type Route struct { Updated int64 `db:"updated"` Version int64 `db:"version"` Waypoints string `db:"waypoints"` + + dirty bool `db:"-" json:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-" json:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-" json:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues RouteOldValues `db:"-" json:"-"` // Old values for webhook comparison +} + +// RouteOldValues holds old field values for webhook comparison +type RouteOldValues struct { + Version int64 +} + +// IsDirty returns true if any field has been modified +func (r *Route) IsDirty() bool { + return r.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (r *Route) ClearDirty() { + r.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (r *Route) IsNewRecord() bool { + return r.newRecord +} + +// Lock acquires the Route's mutex +func (r *Route) Lock() { + r.mu.Lock() +} + +// Unlock releases the Route's mutex +func (r *Route) Unlock() { + r.mu.Unlock() +} + +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (r *Route) snapshotOldValues() { + r.oldValues = RouteOldValues{ + Version: r.Version, + } +} + +// --- Set methods with dirty tracking --- + +func (r *Route) SetName(v string) { + if r.Name != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Name:%s->%s", r.Name, v)) + } + r.Name = v + r.dirty = true + } +} + +func (r *Route) SetShortcode(v string) { + if r.Shortcode != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Shortcode:%s->%s", r.Shortcode, v)) + } + r.Shortcode = v + r.dirty = true + } +} + +func (r *Route) SetDescription(v string) { + if r.Description != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Description:%s->%s", r.Description, v)) + } + r.Description = v + r.dirty = true + } +} + +func (r *Route) SetDistanceMeters(v int64) { + if r.DistanceMeters != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("DistanceMeters:%d->%d", r.DistanceMeters, v)) + } + r.DistanceMeters = v + r.dirty = true + } +} + +func (r *Route) SetDurationSeconds(v int64) { + if r.DurationSeconds != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("DurationSeconds:%d->%d", r.DurationSeconds, v)) + } + r.DurationSeconds = v + r.dirty = true + } +} + +func (r *Route) SetEndFortId(v string) { + if r.EndFortId != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("EndFortId:%s->%s", r.EndFortId, v)) + } + r.EndFortId = v + r.dirty = true + } +} + +func (r *Route) SetEndImage(v string) { + if r.EndImage != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("EndImage:%s->%s", r.EndImage, v)) + } + r.EndImage = v + r.dirty = true + } +} + +func (r *Route) SetEndLat(v float64) { + if !floatAlmostEqual(r.EndLat, v, floatTolerance) { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("EndLat:%f->%f", r.EndLat, v)) + } + r.EndLat = v + r.dirty = true + } +} + +func (r *Route) SetEndLon(v float64) { + if !floatAlmostEqual(r.EndLon, v, floatTolerance) { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("EndLon:%f->%f", r.EndLon, v)) + } + r.EndLon = v + r.dirty = true + } +} + +func (r *Route) SetImage(v string) { + if r.Image != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Image:%s->%s", r.Image, v)) + } + r.Image = v + r.dirty = true + } +} + +func (r *Route) SetImageBorderColor(v string) { + if r.ImageBorderColor != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("ImageBorderColor:%s->%s", r.ImageBorderColor, v)) + } + r.ImageBorderColor = v + r.dirty = true + } } -func getRouteRecord(db db.DbDetails, id string) (*Route, error) { - inMemoryRoute := routeCache.Get(id) - if inMemoryRoute != nil { - route := inMemoryRoute.Value() - return &route, nil - } - - route := Route{} - err := db.GeneralDb.Get(&route, - ` - SELECT * - FROM route - WHERE route.id = ? - `, - id, - ) - statsCollector.IncDbQuery("select route", err) - if err == sql.ErrNoRows { - return nil, nil - } else if err != nil { - return nil, err - } - - routeCache.Set(id, route, ttlcache.DefaultTTL) - return &route, nil -} - -// hasChangesRoute compares two Route structs -func hasChangesRoute(old *Route, new *Route) bool { - return old.Name != new.Name || - old.Shortcode != new.Shortcode || - old.Description != new.Description || - old.DistanceMeters != new.DistanceMeters || - old.DurationSeconds != new.DurationSeconds || - old.EndFortId != new.EndFortId || - !floatAlmostEqual(old.EndLat, new.EndLat, floatTolerance) || - !floatAlmostEqual(old.EndLon, new.EndLon, floatTolerance) || - old.Image != new.Image || - old.ImageBorderColor != new.ImageBorderColor || - old.Reversible != new.Reversible || - old.StartFortId != new.StartFortId || - !floatAlmostEqual(old.StartLat, new.StartLat, floatTolerance) || - !floatAlmostEqual(old.StartLon, new.StartLon, floatTolerance) || - old.Tags != new.Tags || - old.Type != new.Type || - old.Version != new.Version || - old.Waypoints != new.Waypoints -} - -func saveRouteRecord(db db.DbDetails, route *Route) error { - oldRoute, _ := getRouteRecord(db, route.Id) - - if oldRoute != nil && !hasChangesRoute(oldRoute, route) { - if oldRoute.Updated > time.Now().Unix()-900 { - // if a route is unchanged, but we did see it again after 15 minutes, then save again - return nil - } - } - - if oldRoute == nil { - _, err := db.GeneralDb.NamedExec( - ` - INSERT INTO route ( - id, name, shortcode, description, distance_meters, - duration_seconds, end_fort_id, end_image, - end_lat, end_lon, image, image_border_color, - reversible, start_fort_id, start_image, - start_lat, start_lon, tags, type, - updated, version, waypoints - ) - VALUES - ( - :id, :name, :shortcode, :description, :distance_meters, - :duration_seconds, :end_fort_id, - :end_image, :end_lat, :end_lon, :image, - :image_border_color, :reversible, - :start_fort_id, :start_image, :start_lat, - :start_lon, :tags, :type, :updated, - :version, :waypoints - ) - `, - route, - ) - - statsCollector.IncDbQuery("insert route", err) - if err != nil { - return fmt.Errorf("insert route error: %w", err) - } - } else { - _, err := db.GeneralDb.NamedExec( - ` - UPDATE route SET - name = :name, - shortcode = :shortcode, - description = :description, - distance_meters = :distance_meters, - duration_seconds = :duration_seconds, - end_fort_id = :end_fort_id, - end_image = :end_image, - end_lat = :end_lat, - end_lon = :end_lon, - image = :image, - image_border_color = :image_border_color, - reversible = :reversible, - start_fort_id = :start_fort_id, - start_image = :start_image, - start_lat = :start_lat, - start_lon = :start_lon, - tags = :tags, - type = :type, - updated = :updated, - version = :version, - waypoints = :waypoints - WHERE id = :id`, - route, - ) - - statsCollector.IncDbQuery("update route", err) - if err != nil { - return fmt.Errorf("update route error %w", err) - } - } - - routeCache.Set(route.Id, *route, ttlcache.DefaultTTL) - return nil -} - -func (route *Route) updateFromSharedRouteProto(sharedRouteProto *pogo.SharedRouteProto) { - route.Name = sharedRouteProto.GetName() - if sharedRouteProto.GetShortCode() != "" { - route.Shortcode = sharedRouteProto.GetShortCode() - } - route.Description = sharedRouteProto.GetDescription() - // NOTE: Some descriptions have more than 255 runes, which won't fit in our - // varchar(255). - if truncateStr, truncated := util.TruncateUTF8(route.Description, 255); truncated { - log.Warnf("truncating description for route id '%s'. Orig description: %s", - route.Id, - route.Description, - ) - route.Description = truncateStr - } - route.DistanceMeters = sharedRouteProto.GetRouteDistanceMeters() - route.DurationSeconds = sharedRouteProto.GetRouteDurationSeconds() - route.EndFortId = sharedRouteProto.GetEndPoi().GetAnchor().GetFortId() - route.EndImage = sharedRouteProto.GetEndPoi().GetImageUrl() - route.EndLat = sharedRouteProto.GetEndPoi().GetAnchor().GetLatDegrees() - route.EndLon = sharedRouteProto.GetEndPoi().GetAnchor().GetLngDegrees() - route.Image = sharedRouteProto.GetImage().GetImageUrl() - route.ImageBorderColor = sharedRouteProto.GetImage().GetBorderColorHex() - route.Reversible = sharedRouteProto.GetReversible() - route.StartFortId = sharedRouteProto.GetStartPoi().GetAnchor().GetFortId() - route.StartImage = sharedRouteProto.GetStartPoi().GetImageUrl() - route.StartLat = sharedRouteProto.GetStartPoi().GetAnchor().GetLatDegrees() - route.StartLon = sharedRouteProto.GetStartPoi().GetAnchor().GetLngDegrees() - route.Type = int8(sharedRouteProto.GetType()) - route.Updated = time.Now().Unix() - route.Version = sharedRouteProto.GetVersion() - waypoints, _ := json.Marshal(sharedRouteProto.GetWaypoints()) - route.Waypoints = string(waypoints) - - if len(sharedRouteProto.GetTags()) > 0 { - tags, _ := json.Marshal(sharedRouteProto.GetTags()) - route.Tags = null.StringFrom(string(tags)) - } -} - -func UpdateRouteRecordWithSharedRouteProto(db db.DbDetails, sharedRouteProto *pogo.SharedRouteProto) error { - routeMutex, _ := routeStripedMutex.GetLock(sharedRouteProto.GetId()) - routeMutex.Lock() - defer routeMutex.Unlock() - - route, err := getRouteRecord(db, sharedRouteProto.GetId()) - if err != nil { - return err - } - - if route == nil { - route = &Route{ - Id: sharedRouteProto.GetId(), - } - } - - route.updateFromSharedRouteProto(sharedRouteProto) - saveError := saveRouteRecord(db, route) - return saveError +func (r *Route) SetReversible(v bool) { + if r.Reversible != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Reversible:%t->%t", r.Reversible, v)) + } + r.Reversible = v + r.dirty = true + } +} + +func (r *Route) SetStartFortId(v string) { + if r.StartFortId != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("StartFortId:%s->%s", r.StartFortId, v)) + } + r.StartFortId = v + r.dirty = true + } +} + +func (r *Route) SetStartImage(v string) { + if r.StartImage != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("StartImage:%s->%s", r.StartImage, v)) + } + r.StartImage = v + r.dirty = true + } +} + +func (r *Route) SetStartLat(v float64) { + if !floatAlmostEqual(r.StartLat, v, floatTolerance) { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("StartLat:%f->%f", r.StartLat, v)) + } + r.StartLat = v + r.dirty = true + } +} + +func (r *Route) SetStartLon(v float64) { + if !floatAlmostEqual(r.StartLon, v, floatTolerance) { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("StartLon:%f->%f", r.StartLon, v)) + } + r.StartLon = v + r.dirty = true + } +} + +func (r *Route) SetTags(v null.String) { + if r.Tags != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Tags:%s->%s", FormatNull(r.Tags), FormatNull(v))) + } + r.Tags = v + r.dirty = true + } +} + +func (r *Route) SetType(v int8) { + if r.Type != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Type:%d->%d", r.Type, v)) + } + r.Type = v + r.dirty = true + } +} + +func (r *Route) SetVersion(v int64) { + if r.Version != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Version:%d->%d", r.Version, v)) + } + r.Version = v + r.dirty = true + } +} + +func (r *Route) SetWaypoints(v string) { + if r.Waypoints != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Waypoints:%s->%s", r.Waypoints, v)) + } + r.Waypoints = v + r.dirty = true + } +} + +func (r *Route) SetUpdated(v int64) { + if r.Updated != v { + if dbDebugEnabled { + r.changedFields = append(r.changedFields, fmt.Sprintf("Updated:%d->%d", r.Updated, v)) + } + r.Updated = v + r.dirty = true + } } diff --git a/decoder/routes_decode.go b/decoder/routes_decode.go new file mode 100644 index 00000000..cb72fd5a --- /dev/null +++ b/decoder/routes_decode.go @@ -0,0 +1,51 @@ +package decoder + +import ( + "encoding/json" + + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/pogo" + "golbat/util" +) + +func (route *Route) updateFromSharedRouteProto(sharedRouteProto *pogo.SharedRouteProto) { + route.SetName(sharedRouteProto.GetName()) + if sharedRouteProto.GetShortCode() != "" { + route.SetShortcode(sharedRouteProto.GetShortCode()) + } + description := sharedRouteProto.GetDescription() + // NOTE: Some descriptions have more than 255 runes, which won't fit in our + // varchar(255). + if truncateStr, truncated := util.TruncateUTF8(description, 255); truncated { + log.Warnf("truncating description for route id '%s'. Orig description: %s", + route.Id, + description, + ) + description = truncateStr + } + route.SetDescription(description) + route.SetDistanceMeters(sharedRouteProto.GetRouteDistanceMeters()) + route.SetDurationSeconds(sharedRouteProto.GetRouteDurationSeconds()) + route.SetEndFortId(sharedRouteProto.GetEndPoi().GetAnchor().GetFortId()) + route.SetEndImage(sharedRouteProto.GetEndPoi().GetImageUrl()) + route.SetEndLat(sharedRouteProto.GetEndPoi().GetAnchor().GetLatDegrees()) + route.SetEndLon(sharedRouteProto.GetEndPoi().GetAnchor().GetLngDegrees()) + route.SetImage(sharedRouteProto.GetImage().GetImageUrl()) + route.SetImageBorderColor(sharedRouteProto.GetImage().GetBorderColorHex()) + route.SetReversible(sharedRouteProto.GetReversible()) + route.SetStartFortId(sharedRouteProto.GetStartPoi().GetAnchor().GetFortId()) + route.SetStartImage(sharedRouteProto.GetStartPoi().GetImageUrl()) + route.SetStartLat(sharedRouteProto.GetStartPoi().GetAnchor().GetLatDegrees()) + route.SetStartLon(sharedRouteProto.GetStartPoi().GetAnchor().GetLngDegrees()) + route.SetType(int8(sharedRouteProto.GetType())) + route.SetVersion(sharedRouteProto.GetVersion()) + waypoints, _ := json.Marshal(sharedRouteProto.GetWaypoints()) + route.SetWaypoints(string(waypoints)) + + if len(sharedRouteProto.GetTags()) > 0 { + tags, _ := json.Marshal(sharedRouteProto.GetTags()) + route.SetTags(null.StringFrom(string(tags))) + } +} diff --git a/decoder/routes_process.go b/decoder/routes_process.go new file mode 100644 index 00000000..a0c5c83b --- /dev/null +++ b/decoder/routes_process.go @@ -0,0 +1,20 @@ +package decoder + +import ( + "context" + + "golbat/db" + "golbat/pogo" +) + +func UpdateRouteRecordWithSharedRouteProto(ctx context.Context, db db.DbDetails, sharedRouteProto *pogo.SharedRouteProto) error { + route, unlock, err := getOrCreateRouteRecord(ctx, db, sharedRouteProto.GetId()) + if err != nil { + return err + } + defer unlock() + + route.updateFromSharedRouteProto(sharedRouteProto) + saveError := saveRouteRecord(ctx, db, route) + return saveError +} diff --git a/decoder/routes_state.go b/decoder/routes_state.go new file mode 100644 index 00000000..38a0d918 --- /dev/null +++ b/decoder/routes_state.go @@ -0,0 +1,196 @@ +package decoder + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jellydator/ttlcache/v3" + + "golbat/db" +) + +func loadRouteFromDatabase(ctx context.Context, db db.DbDetails, routeId string, route *Route) error { + err := db.GeneralDb.GetContext(ctx, route, + `SELECT * FROM route WHERE route.id = ?`, routeId) + statsCollector.IncDbQuery("select route", err) + return err +} + +// peekRouteRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func peekRouteRecord(routeId string) (*Route, func(), error) { + if item := routeCache.Get(routeId); item != nil { + route := item.Value() + route.Lock() + return route, func() { route.Unlock() }, nil + } + return nil, nil, nil +} + +// getRouteRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getRouteRecordReadOnly(ctx context.Context, db db.DbDetails, routeId string) (*Route, func(), error) { + // Check cache first + if item := routeCache.Get(routeId); item != nil { + route := item.Value() + route.Lock() + return route, func() { route.Unlock() }, nil + } + + dbRoute := Route{} + err := loadRouteFromDatabase(ctx, db, routeId, &dbRoute) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbRoute.ClearDirty() + + // Atomically cache the loaded Route - if another goroutine raced us, + // we'll get their Route and use that instead (ensuring same mutex) + existingRoute, _ := routeCache.GetOrSetFunc(routeId, func() *Route { + return &dbRoute + }) + + route := existingRoute.Value() + route.Lock() + return route, func() { route.Unlock() }, nil +} + +// getRouteRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Caller MUST call returned unlock function if non-nil. +func getRouteRecordForUpdate(ctx context.Context, db db.DbDetails, routeId string) (*Route, func(), error) { + route, unlock, err := getRouteRecordReadOnly(ctx, db, routeId) + if err != nil || route == nil { + return nil, nil, err + } + route.snapshotOldValues() + return route, unlock, nil +} + +// getOrCreateRouteRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreateRouteRecord(ctx context.Context, db db.DbDetails, routeId string) (*Route, func(), error) { + // Create new Route atomically - function only called if key doesn't exist + routeItem, _ := routeCache.GetOrSetFunc(routeId, func() *Route { + return &Route{Id: routeId, newRecord: true} + }) + + route := routeItem.Value() + route.Lock() + + if route.newRecord { + // We should attempt to load from database + err := loadRouteFromDatabase(ctx, db, routeId, route) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + route.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + route.newRecord = false + route.ClearDirty() + } + } + + route.snapshotOldValues() + return route, func() { route.Unlock() }, nil +} + +func saveRouteRecord(ctx context.Context, db db.DbDetails, route *Route) error { + // Skip save if not dirty and not new, unless 15-minute debounce expired + if !route.IsDirty() && !route.IsNewRecord() { + if route.Updated > time.Now().Unix()-GetUpdateThreshold(900) { + // if a route is unchanged, but we did see it again after 15 minutes, then save again + return nil + } + } + + route.SetUpdated(time.Now().Unix()) + + if route.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Route", route.Id, route.changedFields) + } + _, err := db.GeneralDb.NamedExecContext(ctx, + ` + INSERT INTO route ( + id, name, shortcode, description, distance_meters, + duration_seconds, end_fort_id, end_image, + end_lat, end_lon, image, image_border_color, + reversible, start_fort_id, start_image, + start_lat, start_lon, tags, type, + updated, version, waypoints + ) + VALUES + ( + :id, :name, :shortcode, :description, :distance_meters, + :duration_seconds, :end_fort_id, + :end_image, :end_lat, :end_lon, :image, + :image_border_color, :reversible, + :start_fort_id, :start_image, :start_lat, + :start_lon, :tags, :type, :updated, + :version, :waypoints + ) + `, + route, + ) + + statsCollector.IncDbQuery("insert route", err) + if err != nil { + return fmt.Errorf("insert route error: %w", err) + } + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Route", route.Id, route.changedFields) + } + _, err := db.GeneralDb.NamedExecContext(ctx, + ` + UPDATE route SET + name = :name, + shortcode = :shortcode, + description = :description, + distance_meters = :distance_meters, + duration_seconds = :duration_seconds, + end_fort_id = :end_fort_id, + end_image = :end_image, + end_lat = :end_lat, + end_lon = :end_lon, + image = :image, + image_border_color = :image_border_color, + reversible = :reversible, + start_fort_id = :start_fort_id, + start_image = :start_image, + start_lat = :start_lat, + start_lon = :start_lon, + tags = :tags, + type = :type, + updated = :updated, + version = :version, + waypoints = :waypoints + WHERE id = :id`, + route, + ) + + statsCollector.IncDbQuery("update route", err) + if err != nil { + return fmt.Errorf("update route error %w", err) + } + } + + if dbDebugEnabled { + route.changedFields = route.changedFields[:0] + } + route.ClearDirty() + if route.IsNewRecord() { + routeCache.Set(route.Id, route, ttlcache.DefaultTTL) + route.newRecord = false + } + return nil +} diff --git a/decoder/s2cell.go b/decoder/s2cell.go index 506eb952..60513492 100644 --- a/decoder/s2cell.go +++ b/decoder/s2cell.go @@ -2,13 +2,16 @@ package decoder import ( "context" - "golbat/db" + "strconv" + "strings" "time" + "golbat/db" + "github.com/golang/geo/s2" + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) type S2Cell struct { @@ -30,24 +33,27 @@ type S2Cell struct { func saveS2CellRecords(ctx context.Context, db db.DbDetails, cellIds []uint64) { now := time.Now().Unix() - outputCellIds := []S2Cell{} + var outputCellIds []*S2Cell // prepare list of cells to update for _, cellId := range cellIds { - var s2Cell = S2Cell{} + var s2Cell *S2Cell if c := s2CellCache.Get(cellId); c != nil { cachedCell := c.Value() - if cachedCell.Updated > now-900 { + if cachedCell.Updated > now-GetUpdateThreshold(900) { continue } s2Cell = cachedCell } else { mapS2Cell := s2.CellFromCellID(s2.CellID(cellId)) + s2Cell = &S2Cell{} s2Cell.Id = cellId s2Cell.Latitude = mapS2Cell.CapBound().RectBound().Center().Lat.Degrees() s2Cell.Longitude = mapS2Cell.CapBound().RectBound().Center().Lng.Degrees() s2Cell.Level = null.IntFrom(int64(mapS2Cell.Level())) + + s2CellCache.Set(s2Cell.Id, s2Cell, ttlcache.DefaultTTL) } s2Cell.Updated = now @@ -58,6 +64,14 @@ func saveS2CellRecords(ctx context.Context, db db.DbDetails, cellIds []uint64) { return } + if dbDebugEnabled { + var updatedCells []string + for _, s2cell := range outputCellIds { + updatedCells = append(updatedCells, strconv.FormatUint(s2cell.Id, 10)) + } + log.Debugf("[DB_UPDATE] S2Cell Updated cells: %s", strings.Join(updatedCells, ",")) + } + // run bulk query _, err := db.GeneralDb.NamedExecContext(ctx, ` INSERT INTO s2cell (id, center_lat, center_lon, level, updated) @@ -71,8 +85,8 @@ func saveS2CellRecords(ctx context.Context, db db.DbDetails, cellIds []uint64) { return } - // set cache - for _, cellId := range outputCellIds { - s2CellCache.Set(cellId.Id, cellId, ttlcache.DefaultTTL) - } + // since cache is now a pointer, ttl will already have been updated + //for _, cellId := range outputCellIds { + // s2CellCache.Set(cellId.Id, cellId, ttlcache.DefaultTTL) + //} } diff --git a/decoder/scanarea.go b/decoder/scanarea.go index 9e7831ae..7b52636f 100644 --- a/decoder/scanarea.go +++ b/decoder/scanarea.go @@ -1,15 +1,17 @@ package decoder import ( + "strings" + "golbat/config" "golbat/geo" - "strings" ) type ScanParameters struct { ProcessPokemon bool ProcessWild bool ProcessNearby bool + ProcessNearbyCell bool ProcessWeather bool ProcessPokestops bool ProcessGyms bool @@ -56,6 +58,16 @@ func FindScanConfiguration(scanContext string, lat, lon float64) ScanParameters return *value } + defaultTrueFirst := func(value *bool, value2 *bool) bool { + if value != nil { + return *value + } + if value2 != nil { + return *value2 + } + return true + } + defaultFromWeatherConfig := func(value *bool, weatherDefault bool) bool { if value == nil { return weatherDefault @@ -67,6 +79,7 @@ func FindScanConfiguration(scanContext string, lat, lon float64) ScanParameters ProcessPokemon: defaultTrue(rule.ProcessPokemon), ProcessWild: defaultTrue(rule.ProcessWilds), ProcessNearby: defaultTrue(rule.ProcessNearby), + ProcessNearbyCell: defaultTrueFirst(rule.ProcessNearbyCell, rule.ProcessNearby), ProcessCells: defaultTrue(rule.ProcessCells), ProcessWeather: defaultTrue(rule.ProcessWeather), ProcessPokestops: defaultTrue(rule.ProcessPokestops), @@ -82,6 +95,7 @@ func FindScanConfiguration(scanContext string, lat, lon float64) ScanParameters ProcessPokemon: true, ProcessWild: true, ProcessNearby: true, + ProcessNearbyCell: true, ProcessCells: true, ProcessWeather: true, ProcessGyms: true, diff --git a/decoder/sharded_cache.go b/decoder/sharded_cache.go new file mode 100644 index 00000000..84777838 --- /dev/null +++ b/decoder/sharded_cache.go @@ -0,0 +1,125 @@ +package decoder + +import ( + "context" + "hash/fnv" + "time" + + "github.com/jellydator/ttlcache/v3" +) + +// ShardedCache is a generic sharded cache for improved concurrency. +// It distributes entries across multiple ttlcache instances to reduce lock contention. +type ShardedCache[K comparable, V any] struct { + shards []*ttlcache.Cache[K, V] + keyToShard func(K) uint64 +} + +// ShardedCacheConfig holds configuration for creating a ShardedCache +type ShardedCacheConfig[K comparable, V any] struct { + NumShards int + TTL time.Duration + KeyToShard func(K) uint64 + DisableTouchOnHit bool +} + +// NewShardedCache creates a new sharded cache with the given configuration. +// The keyToShard function converts keys to uint64 for shard selection. +func NewShardedCache[K comparable, V any](config ShardedCacheConfig[K, V]) *ShardedCache[K, V] { + sc := &ShardedCache[K, V]{ + shards: make([]*ttlcache.Cache[K, V], config.NumShards), + keyToShard: config.KeyToShard, + } + + for i := 0; i < config.NumShards; i++ { + opts := []ttlcache.Option[K, V]{ + ttlcache.WithTTL[K, V](config.TTL), + } + if config.DisableTouchOnHit { + opts = append(opts, ttlcache.WithDisableTouchOnHit[K, V]()) + } + sc.shards[i] = ttlcache.New[K, V](opts...) + go sc.shards[i].Start() + } + + return sc +} + +// getShard returns the cache shard for the given key +func (sc *ShardedCache[K, V]) getShard(key K) *ttlcache.Cache[K, V] { + return sc.shards[sc.keyToShard(key)%uint64(len(sc.shards))] +} + +// Get retrieves an item from the appropriate shard +func (sc *ShardedCache[K, V]) Get(key K) *ttlcache.Item[K, V] { + return sc.getShard(key).Get(key) +} + +// Set stores an item in the appropriate shard +func (sc *ShardedCache[K, V]) Set(key K, value V, ttl time.Duration) { + sc.getShard(key).Set(key, value, ttl) +} + +// Delete removes an item from the appropriate shard +func (sc *ShardedCache[K, V]) Delete(key K) { + sc.getShard(key).Delete(key) +} + +// Range iterates over all items in all shards. +// The callback should return true to continue iteration or false to stop. +func (sc *ShardedCache[K, V]) Range(fn func(*ttlcache.Item[K, V]) bool) { + for _, shard := range sc.shards { + shard.Range(fn) + } +} + +// OnEviction sets an eviction callback on all shards +func (sc *ShardedCache[K, V]) OnEviction(fn func(context.Context, ttlcache.EvictionReason, *ttlcache.Item[K, V])) { + for _, shard := range sc.shards { + shard.OnEviction(fn) + } +} + +// Len returns the total number of items across all shards +func (sc *ShardedCache[K, V]) Len() int { + total := 0 + for _, shard := range sc.shards { + total += shard.Len() + } + return total +} + +// DeleteAll removes all items from all shards +func (sc *ShardedCache[K, V]) DeleteAll() { + for _, shard := range sc.shards { + shard.DeleteAll() + } +} + +// GetOrSetFunc retrieves an item from the cache by the provided key. +// If the element is not found, it is created by executing the fn function +// with the provided options and then returned. +// The bool return value is true if the item was found, false if created +// during the execution of the method. +func (sc *ShardedCache[K, V]) GetOrSetFunc(key K, createFunc func() V, opts ...ttlcache.Option[K, V]) (*ttlcache.Item[K, V], bool) { + return sc.getShard(key).GetOrSetFunc(key, createFunc, opts...) +} + +// --- Key conversion helpers --- + +// Uint64KeyToShard is the identity function for uint64 keys +func Uint64KeyToShard(key uint64) uint64 { + return key +} + +// Int64KeyToShard converts int64 keys to uint64 for sharding +func Int64KeyToShard(key int64) uint64 { + return uint64(key) +} + +// StringKeyToShard hashes string keys to uint64 for sharding using FNV-1a +func StringKeyToShard(key string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(key)) + return h.Sum64() +} diff --git a/decoder/spawnpoint.go b/decoder/spawnpoint.go index 5867daa1..0f5f0f3b 100644 --- a/decoder/spawnpoint.go +++ b/decoder/spawnpoint.go @@ -3,25 +3,35 @@ package decoder import ( "context" "database/sql" - "golbat/db" - "golbat/pogo" + "errors" + "fmt" "strconv" + "sync" "time" + "golbat/db" + "golbat/pogo" + + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) // Spawnpoint struct. -// REMINDER! Keep hasChangesSpawnpoint updated after making changes +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Spawnpoint struct { + mu sync.Mutex `db:"-" json:"-"` // Object-level mutex + Id int64 `db:"id"` Lat float64 `db:"lat"` Lon float64 `db:"lon"` Updated int64 `db:"updated"` LastSeen int64 `db:"last_seen"` DespawnSec null.Int `db:"despawn_sec"` + + dirty bool `db:"-" json:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-" json:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-" json:"-"` // Track which fields changed (only when dbDebugEnabled) } //CREATE TABLE `spawnpoint` ( @@ -37,59 +47,195 @@ type Spawnpoint struct { //KEY `ix_last_seen` (`last_seen`) //) -func getSpawnpointRecord(ctx context.Context, db db.DbDetails, spawnpointId int64) (*Spawnpoint, error) { - inMemorySpawnpoint := spawnpointCache.Get(spawnpointId) - if inMemorySpawnpoint != nil { - spawnpoint := inMemorySpawnpoint.Value() - return &spawnpoint, nil +// IsDirty returns true if any field has been modified +func (s *Spawnpoint) IsDirty() bool { + return s.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (s *Spawnpoint) ClearDirty() { + s.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (s *Spawnpoint) IsNewRecord() bool { + return s.newRecord +} + +// Lock acquires the Spawnpoint's mutex +func (s *Spawnpoint) Lock() { + s.mu.Lock() +} + +// Unlock releases the Spawnpoint's mutex +func (s *Spawnpoint) Unlock() { + s.mu.Unlock() +} + +// --- Set methods with dirty tracking --- + +func (s *Spawnpoint) SetLat(v float64) { + if !floatAlmostEqual(s.Lat, v, floatTolerance) { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("Lat:%f->%f", s.Lat, v)) + } + s.Lat = v + s.dirty = true } - spawnpoint := Spawnpoint{} +} - err := db.GeneralDb.GetContext(ctx, &spawnpoint, "SELECT id, lat, lon, updated, last_seen, despawn_sec FROM spawnpoint WHERE id = ?", spawnpointId) +func (s *Spawnpoint) SetLon(v float64) { + if !floatAlmostEqual(s.Lon, v, floatTolerance) { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("Lon:%f->%f", s.Lon, v)) + } + s.Lon = v + s.dirty = true + } +} - statsCollector.IncDbQuery("select spawnpoint", err) - if err == sql.ErrNoRows { - return nil, nil +// SetDespawnSec sets despawn_sec with 2-second tolerance logic +func (s *Spawnpoint) SetDespawnSec(v null.Int) { + // Handle validity changes + if (s.DespawnSec.Valid && !v.Valid) || (!s.DespawnSec.Valid && v.Valid) { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("DespawnSec:%s->%s", FormatNull(s.DespawnSec), FormatNull(v))) + } + s.DespawnSec = v + s.dirty = true + return } - if err != nil { - return &Spawnpoint{Id: spawnpointId}, err + // Both invalid - no change + if !s.DespawnSec.Valid && !v.Valid { + return } - spawnpointCache.Set(spawnpointId, spawnpoint, ttlcache.DefaultTTL) - return &spawnpoint, nil + // Both valid - check with tolerance + oldVal := s.DespawnSec.Int64 + newVal := v.Int64 + + // Handle wraparound at hour boundary (0/3600) + if oldVal <= 1 && newVal >= 3598 { + return + } + if newVal <= 1 && oldVal >= 3598 { + return + } + + // Allow 2-second tolerance for despawn time + if Abs(oldVal-newVal) > 2 { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("DespawnSec:%s->%s", FormatNull(s.DespawnSec), FormatNull(v))) + } + s.DespawnSec = v + s.dirty = true + } } -func Abs(x int64) int64 { - if x < 0 { - return -x +func (s *Spawnpoint) SetUpdated(v int64) { + if s.Updated != v { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("Updated:%d->%d", s.Updated, v)) + } + s.Updated = v + s.dirty = true } - return x } -func hasChangesSpawnpoint(old *Spawnpoint, new *Spawnpoint) bool { - if !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) || - (old.DespawnSec.Valid && !new.DespawnSec.Valid) || - (!old.DespawnSec.Valid && new.DespawnSec.Valid) { - return true +func (s *Spawnpoint) SetLastSeen(v int64) { + if s.LastSeen != v { + if dbDebugEnabled { + s.changedFields = append(s.changedFields, fmt.Sprintf("LastSeen:%d->%d", s.LastSeen, v)) + } + s.LastSeen = v + s.dirty = true } - if !old.DespawnSec.Valid && !new.DespawnSec.Valid { - return false +} + +func loadSpawnpointFromDatabase(ctx context.Context, db db.DbDetails, spawnpointId int64, spawnpoint *Spawnpoint) error { + err := db.GeneralDb.GetContext(ctx, spawnpoint, + "SELECT id, lat, lon, updated, last_seen, despawn_sec FROM spawnpoint WHERE id = ?", spawnpointId) + statsCollector.IncDbQuery("select spawnpoint", err) + return err +} + +// peekSpawnpointRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func peekSpawnpointRecord(spawnpointId int64) (*Spawnpoint, func(), error) { + if item := spawnpointCache.Get(spawnpointId); item != nil { + spawnpoint := item.Value() + spawnpoint.Lock() + return spawnpoint, func() { spawnpoint.Unlock() }, nil } + return nil, nil, nil +} - // Ignore small movements in despawn time - oldDespawnSec := old.DespawnSec.Int64 - newDespawnSec := new.DespawnSec.Int64 +// getSpawnpointRecord acquires lock. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getSpawnpointRecord(ctx context.Context, db db.DbDetails, spawnpointId int64) (*Spawnpoint, func(), error) { + // Check cache first + if item := spawnpointCache.Get(spawnpointId); item != nil { + spawnpoint := item.Value() + spawnpoint.Lock() + return spawnpoint, func() { spawnpoint.Unlock() }, nil + } - if oldDespawnSec <= 1 && newDespawnSec >= 3598 { - return false + dbSpawnpoint := Spawnpoint{} + err := loadSpawnpointFromDatabase(ctx, db, spawnpointId, &dbSpawnpoint) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil } - if newDespawnSec <= 1 && oldDespawnSec >= 3598 { - return false + if err != nil { + return nil, nil, err } + dbSpawnpoint.ClearDirty() + + // Atomically cache the loaded Spawnpoint - if another goroutine raced us, + // we'll get their Spawnpoint and use that instead (ensuring same mutex) + existingSpawnpoint, _ := spawnpointCache.GetOrSetFunc(spawnpointId, func() *Spawnpoint { + return &dbSpawnpoint + }) + + spawnpoint := existingSpawnpoint.Value() + spawnpoint.Lock() + return spawnpoint, func() { spawnpoint.Unlock() }, nil +} + +// getOrCreateSpawnpointRecord gets existing or creates new, locked. +// Caller MUST call returned unlock function. +func getOrCreateSpawnpointRecord(ctx context.Context, db db.DbDetails, spawnpointId int64) (*Spawnpoint, func(), error) { + // Create new Spawnpoint atomically - function only called if key doesn't exist + spawnpointItem, _ := spawnpointCache.GetOrSetFunc(spawnpointId, func() *Spawnpoint { + return &Spawnpoint{Id: spawnpointId, newRecord: true} + }) - return Abs(old.DespawnSec.Int64-new.DespawnSec.Int64) > 2 + spawnpoint := spawnpointItem.Value() + spawnpoint.Lock() + + if spawnpoint.newRecord { + // We should attempt to load from database + err := loadSpawnpointFromDatabase(ctx, db, spawnpointId, spawnpoint) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + spawnpoint.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + spawnpoint.newRecord = false + spawnpoint.ClearDirty() + } + } + + return spawnpoint, func() { spawnpoint.Unlock() }, nil +} + +func Abs(x int64) int64 { + if x < 0 { + return -x + } + return x } func spawnpointUpdateFromWild(ctx context.Context, db db.DbDetails, wildPokemon *pogo.WildPokemonProto, timestampMs int64) { @@ -103,39 +249,50 @@ func spawnpointUpdateFromWild(ctx context.Context, db db.DbDetails, wildPokemon date := time.Unix(expireTimeStamp, 0) secondOfHour := date.Second() + date.Minute()*60 - spawnpoint := Spawnpoint{ - Id: spawnId, - Lat: wildPokemon.Latitude, - Lon: wildPokemon.Longitude, - DespawnSec: null.IntFrom(int64(secondOfHour)), + + spawnpoint, unlock, err := getOrCreateSpawnpointRecord(ctx, db, spawnId) + if err != nil { + log.Errorf("getOrCreateSpawnpointRecord: %s", err) + return } - spawnpointUpdate(ctx, db, &spawnpoint) + spawnpoint.SetLat(wildPokemon.Latitude) + spawnpoint.SetLon(wildPokemon.Longitude) + spawnpoint.SetDespawnSec(null.IntFrom(int64(secondOfHour))) + spawnpointUpdate(ctx, db, spawnpoint) + unlock() } else { - spawnPoint, _ := getSpawnpointRecord(ctx, db, spawnId) - if spawnPoint == nil { - spawnpoint := Spawnpoint{ - Id: spawnId, - Lat: wildPokemon.Latitude, - Lon: wildPokemon.Longitude, - } - spawnpointUpdate(ctx, db, &spawnpoint) + spawnpoint, unlock, err := getOrCreateSpawnpointRecord(ctx, db, spawnId) + if err != nil { + log.Errorf("getOrCreateSpawnpointRecord: %s", err) + return + } + if spawnpoint.newRecord { + spawnpoint.SetLat(wildPokemon.Latitude) + spawnpoint.SetLon(wildPokemon.Longitude) + spawnpointUpdate(ctx, db, spawnpoint) } else { - spawnpointSeen(ctx, db, spawnId) + spawnpointSeen(ctx, db, spawnpoint) } + unlock() } } func spawnpointUpdate(ctx context.Context, db db.DbDetails, spawnpoint *Spawnpoint) { - oldSpawnpoint, _ := getSpawnpointRecord(ctx, db, spawnpoint.Id) - - if oldSpawnpoint != nil && !hasChangesSpawnpoint(oldSpawnpoint, spawnpoint) { + // Skip save if not dirty and not new + if !spawnpoint.IsDirty() && !spawnpoint.IsNewRecord() { return } - //log.Println(cmp.Diff(oldSpawnpoint, spawnpoint)) + spawnpoint.SetUpdated(time.Now().Unix()) // ensure future updates are set correctly + spawnpoint.SetLastSeen(time.Now().Unix()) // ensure future updates are set correctly - spawnpoint.Updated = time.Now().Unix() // ensure future updates are set correctly - spawnpoint.LastSeen = time.Now().Unix() // ensure future updates are set correctly + if dbDebugEnabled { + if spawnpoint.IsNewRecord() { + dbDebugLog("INSERT", "Spawnpoint", strconv.FormatInt(spawnpoint.Id, 10), spawnpoint.changedFields) + } else { + dbDebugLog("UPDATE", "Spawnpoint", strconv.FormatInt(spawnpoint.Id, 10), spawnpoint.changedFields) + } + } _, err := db.GeneralDb.NamedExecContext(ctx, "INSERT INTO spawnpoint (id, lat, lon, updated, last_seen, despawn_sec)"+ "VALUES (:id, :lat, :lon, :updated, :last_seen, :despawn_sec)"+ @@ -152,30 +309,30 @@ func spawnpointUpdate(ctx context.Context, db db.DbDetails, spawnpoint *Spawnpoi return } - spawnpointCache.Set(spawnpoint.Id, *spawnpoint, ttlcache.DefaultTTL) -} - -func spawnpointSeen(ctx context.Context, db db.DbDetails, spawnpointId int64) { - inMemorySpawnpoint := spawnpointCache.Get(spawnpointId) - if inMemorySpawnpoint == nil { - // This should never happen, since all routes here have previously created a spawnpoint in the cache - return + spawnpoint.ClearDirty() + if spawnpoint.IsNewRecord() { + spawnpoint.newRecord = false + spawnpointCache.Set(spawnpoint.Id, spawnpoint, ttlcache.DefaultTTL) } +} - spawnpoint := inMemorySpawnpoint.Value() +// spawnpointSeen updates the last_seen timestamp for a spawnpoint. +// The spawnpoint must already be locked by the caller. +func spawnpointSeen(ctx context.Context, db db.DbDetails, spawnpoint *Spawnpoint) { now := time.Now().Unix() - if now-spawnpoint.LastSeen > 3600 { - spawnpoint.LastSeen = now + // update at least every 6 hours (21600s). If reduce_updates is enabled, use 12 hours. + if now-spawnpoint.LastSeen > GetUpdateThreshold(21600) { + spawnpoint.SetLastSeen(now) _, err := db.GeneralDb.ExecContext(ctx, "UPDATE spawnpoint "+ "SET last_seen=? "+ - "WHERE id = ? ", now, spawnpointId) + "WHERE id = ? ", now, spawnpoint.Id) statsCollector.IncDbQuery("update spawnpoint", err) if err != nil { log.Printf("Error updating spawnpoint last seen %s", err) return } - spawnpointCache.Set(spawnpoint.Id, spawnpoint, ttlcache.DefaultTTL) + // Cache already contains a pointer, no need to update } } diff --git a/decoder/station.go b/decoder/station.go index 34b0de81..8a7b7bcd 100644 --- a/decoder/station.go +++ b/decoder/station.go @@ -1,23 +1,17 @@ package decoder import ( - "context" - "database/sql" - "encoding/json" - "errors" "fmt" - "golbat/db" - "golbat/pogo" - "golbat/util" - "golbat/webhooks" - "time" - - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" + "sync" + + "github.com/guregu/null/v6" ) +// Station struct. +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Station struct { + mu sync.Mutex `db:"-" json:"-"` // Object-level mutex + Id string `db:"id"` Lat float64 `db:"lat"` Lon float64 `db:"lon"` @@ -47,301 +41,332 @@ type Station struct { TotalStationedPokemon null.Int `db:"total_stationed_pokemon"` TotalStationedGmax null.Int `db:"total_stationed_gmax"` StationedPokemon null.String `db:"stationed_pokemon"` + + dirty bool `db:"-" json:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-" json:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-" json:"-"` // Track which fields changed (only when dbDebugEnabled) + + oldValues StationOldValues `db:"-" json:"-"` // Old values for webhook comparison +} + +// StationOldValues holds old field values for webhook comparison +type StationOldValues struct { + EndTime int64 + BattleEnd null.Int + BattlePokemonId null.Int + BattlePokemonForm null.Int + BattlePokemonCostume null.Int + BattlePokemonGender null.Int + BattlePokemonBreadMode null.Int +} + +// IsDirty returns true if any field has been modified +func (station *Station) IsDirty() bool { + return station.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (station *Station) ClearDirty() { + station.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (station *Station) IsNewRecord() bool { + return station.newRecord } -func getStationRecord(ctx context.Context, db db.DbDetails, stationId string) (*Station, error) { - inMemoryStation := stationCache.Get(stationId) - if inMemoryStation != nil { - station := inMemoryStation.Value() - return &station, nil +// Lock acquires the Station's mutex +func (station *Station) Lock() { + station.mu.Lock() +} + +// Unlock releases the Station's mutex +func (station *Station) Unlock() { + station.mu.Unlock() +} + +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (station *Station) snapshotOldValues() { + station.oldValues = StationOldValues{ + EndTime: station.EndTime, + BattleEnd: station.BattleEnd, + BattlePokemonId: station.BattlePokemonId, + BattlePokemonForm: station.BattlePokemonForm, + BattlePokemonCostume: station.BattlePokemonCostume, + BattlePokemonGender: station.BattlePokemonGender, + BattlePokemonBreadMode: station.BattlePokemonBreadMode, } - station := Station{} - err := db.GeneralDb.GetContext(ctx, &station, - ` - SELECT id, lat, lon, name, cell_id, start_time, end_time, cooldown_complete, is_battle_available, is_inactive, updated, battle_level, battle_start, battle_end, battle_pokemon_id, battle_pokemon_form, battle_pokemon_costume, battle_pokemon_gender, battle_pokemon_alignment, battle_pokemon_bread_mode, battle_pokemon_move_1, battle_pokemon_move_2, battle_pokemon_stamina, battle_pokemon_cp_multiplier, total_stationed_pokemon, total_stationed_gmax, stationed_pokemon - FROM station WHERE id = ? - `, stationId) - statsCollector.IncDbQuery("select station", err) - - if errors.Is(err, sql.ErrNoRows) { - return nil, nil +} + +// --- Set methods with dirty tracking --- + +func (station *Station) SetId(v string) { + if station.Id != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("Id:%s->%s", station.Id, v)) + } + station.Id = v + station.dirty = true } +} - if err != nil { - return nil, err +func (station *Station) SetLat(v float64) { + if !floatAlmostEqual(station.Lat, v, floatTolerance) { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("Lat:%f->%f", station.Lat, v)) + } + station.Lat = v + station.dirty = true } - return &station, nil } -func saveStationRecord(ctx context.Context, db db.DbDetails, station *Station) { - oldStation, _ := getStationRecord(ctx, db, station.Id) - now := time.Now().Unix() - if oldStation != nil && !hasChangesStation(oldStation, station) { - if oldStation.Updated > now-900 { - // if a gym is unchanged, but we did see it again after 15 minutes, then save again - return +func (station *Station) SetLon(v float64) { + if !floatAlmostEqual(station.Lon, v, floatTolerance) { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("Lon:%f->%f", station.Lon, v)) } + station.Lon = v + station.dirty = true } +} - station.Updated = now +func (station *Station) SetName(v string) { + if station.Name != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("Name:%s->%s", station.Name, v)) + } + station.Name = v + station.dirty = true + } +} - //log.Traceln(cmp.Diff(oldStation, station)) - if oldStation == nil { - res, err := db.GeneralDb.NamedExecContext(ctx, - ` - INSERT INTO station (id, lat, lon, name, cell_id, start_time, end_time, cooldown_complete, is_battle_available, is_inactive, updated, battle_level, battle_start, battle_end, battle_pokemon_id, battle_pokemon_form, battle_pokemon_costume, battle_pokemon_gender, battle_pokemon_alignment, battle_pokemon_bread_mode, battle_pokemon_move_1, battle_pokemon_move_2, battle_pokemon_stamina, battle_pokemon_cp_multiplier, total_stationed_pokemon, total_stationed_gmax, stationed_pokemon) - VALUES (:id,:lat,:lon,:name,:cell_id,:start_time,:end_time,:cooldown_complete,:is_battle_available,:is_inactive,:updated,:battle_level,:battle_start,:battle_end,:battle_pokemon_id,:battle_pokemon_form,:battle_pokemon_costume,:battle_pokemon_gender,:battle_pokemon_alignment,:battle_pokemon_bread_mode,:battle_pokemon_move_1,:battle_pokemon_move_2,:battle_pokemon_stamina,:battle_pokemon_cp_multiplier,:total_stationed_pokemon,:total_stationed_gmax,:stationed_pokemon) - `, station) +func (station *Station) SetCellId(v int64) { + if station.CellId != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("CellId:%d->%d", station.CellId, v)) + } + station.CellId = v + station.dirty = true + } +} - statsCollector.IncDbQuery("insert station", err) - if err != nil { - log.Errorf("insert station: %s", err) - return +func (station *Station) SetStartTime(v int64) { + if station.StartTime != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("StartTime:%d->%d", station.StartTime, v)) } - _, _ = res, err - } else { - res, err := db.GeneralDb.NamedExecContext(ctx, ` - UPDATE station - SET - lat = :lat, - lon = :lon, - name = :name, - cell_id = :cell_id, - start_time = :start_time, - end_time = :end_time, - cooldown_complete = :cooldown_complete, - is_battle_available = :is_battle_available, - is_inactive = :is_inactive, - updated = :updated, - battle_level = :battle_level, - battle_start = :battle_start, - battle_end = :battle_end, - battle_pokemon_id = :battle_pokemon_id, - battle_pokemon_form = :battle_pokemon_form, - battle_pokemon_costume = :battle_pokemon_costume, - battle_pokemon_gender = :battle_pokemon_gender, - battle_pokemon_alignment = :battle_pokemon_alignment, - battle_pokemon_bread_mode = :battle_pokemon_bread_mode, - battle_pokemon_move_1 = :battle_pokemon_move_1, - battle_pokemon_move_2 = :battle_pokemon_move_2, - battle_pokemon_stamina = :battle_pokemon_stamina, - battle_pokemon_cp_multiplier = :battle_pokemon_cp_multiplier, - total_stationed_pokemon = :total_stationed_pokemon, - total_stationed_gmax = :total_stationed_gmax, - stationed_pokemon = :stationed_pokemon - WHERE id = :id - `, station, - ) - statsCollector.IncDbQuery("update station", err) - if err != nil { - log.Errorf("Update station %s", err) + station.StartTime = v + station.dirty = true + } +} + +func (station *Station) SetEndTime(v int64) { + if station.EndTime != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("EndTime:%d->%d", station.EndTime, v)) } - _, _ = res, err + station.EndTime = v + station.dirty = true } +} - stationCache.Set(station.Id, *station, ttlcache.DefaultTTL) - createStationWebhooks(oldStation, station) - -} - -// hasChangesStation compares two Station structs -// Float tolerance: Lat, Lon -func hasChangesStation(old *Station, new *Station) bool { - return old.Id != new.Id || - old.Name != new.Name || - old.StartTime != new.StartTime || - old.EndTime != new.EndTime || - old.StationedPokemon != new.StationedPokemon || - old.CooldownComplete != new.CooldownComplete || - old.IsBattleAvailable != new.IsBattleAvailable || - old.BattleLevel != new.BattleLevel || - old.BattleStart != new.BattleStart || - old.BattleEnd != new.BattleEnd || - old.BattlePokemonId != new.BattlePokemonId || - old.BattlePokemonForm != new.BattlePokemonForm || - old.BattlePokemonCostume != new.BattlePokemonCostume || - old.BattlePokemonGender != new.BattlePokemonGender || - old.BattlePokemonAlignment != new.BattlePokemonAlignment || - old.BattlePokemonBreadMode != new.BattlePokemonBreadMode || - old.BattlePokemonMove1 != new.BattlePokemonMove1 || - old.BattlePokemonMove2 != new.BattlePokemonMove2 || - old.BattlePokemonStamina != new.BattlePokemonStamina || - old.BattlePokemonCpMultiplier != new.BattlePokemonCpMultiplier || - !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) -} - -func (station *Station) updateFromStationProto(stationProto *pogo.StationProto, cellId uint64) *Station { - station.Id = stationProto.Id - station.Name = stationProto.Name - // NOTE: Some names have more than 255 runes, which won't fit in our - // varchar(255). - if truncateStr, truncated := util.TruncateUTF8(stationProto.Name, 255); truncated { - log.Warnf("truncating name for station id '%s'. Orig name: %s", - stationProto.Id, - stationProto.Name, - ) - station.Name = truncateStr +func (station *Station) SetCooldownComplete(v int64) { + if station.CooldownComplete != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("CooldownComplete:%d->%d", station.CooldownComplete, v)) + } + station.CooldownComplete = v + station.dirty = true } - station.Lat = stationProto.Lat - station.Lon = stationProto.Lng - station.StartTime = stationProto.StartTimeMs / 1000 - station.EndTime = stationProto.EndTimeMs / 1000 - station.CooldownComplete = stationProto.CooldownCompleteMs - station.IsBattleAvailable = stationProto.IsBreadBattleAvailable - if battleDetails := stationProto.BattleDetails; battleDetails != nil { - station.BattleLevel = null.IntFrom(int64(battleDetails.BattleLevel)) - station.BattleStart = null.IntFrom(battleDetails.BattleWindowStartMs / 1000) - station.BattleEnd = null.IntFrom(battleDetails.BattleWindowEndMs / 1000) - if pokemon := battleDetails.BattlePokemon; pokemon != nil { - station.BattlePokemonId = null.IntFrom(int64(pokemon.PokemonId)) - station.BattlePokemonMove1 = null.IntFrom(int64(pokemon.Move1)) - station.BattlePokemonMove2 = null.IntFrom(int64(pokemon.Move2)) - station.BattlePokemonForm = null.IntFrom(int64(pokemon.PokemonDisplay.Form)) - station.BattlePokemonCostume = null.IntFrom(int64(pokemon.PokemonDisplay.Costume)) - station.BattlePokemonGender = null.IntFrom(int64(pokemon.PokemonDisplay.Gender)) - station.BattlePokemonAlignment = null.IntFrom(int64(pokemon.PokemonDisplay.Alignment)) - station.BattlePokemonBreadMode = null.IntFrom(int64(pokemon.PokemonDisplay.BreadModeEnum)) - station.BattlePokemonStamina = null.IntFrom(int64(pokemon.Stamina)) - station.BattlePokemonCpMultiplier = null.FloatFrom(float64(pokemon.CpMultiplier)) - if rewardPokemon := battleDetails.RewardPokemon; rewardPokemon != nil && pokemon.PokemonId != rewardPokemon.PokemonId { - log.Infof("[DYNAMAX] Pokemon reward differs from battle: Battle %v - Reward %v", pokemon, rewardPokemon) - } +} + +func (station *Station) SetIsBattleAvailable(v bool) { + if station.IsBattleAvailable != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("IsBattleAvailable:%t->%t", station.IsBattleAvailable, v)) } + station.IsBattleAvailable = v + station.dirty = true } - station.CellId = int64(cellId) - return station -} - -func (station *Station) updateFromGetStationedPokemonDetailsOutProto(stationProto *pogo.GetStationedPokemonDetailsOutProto) *Station { - type stationedPokemonDetail struct { - PokemonId int `json:"pokemon_id"` - Form int `json:"form"` - Costume int `json:"costume"` - Gender int `json:"gender"` - Shiny bool `json:"shiny,omitempty"` - TempEvolution int `json:"temp_evolution,omitempty"` - TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` - Alignment int `json:"alignment,omitempty"` - Badge int `json:"badge,omitempty"` - Background *int64 `json:"background,omitempty"` - BreadMode int `json:"bread_mode"` +} + +func (station *Station) SetIsInactive(v bool) { + if station.IsInactive != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("IsInactive:%t->%t", station.IsInactive, v)) + } + station.IsInactive = v + station.dirty = true } +} - var stationedPokemon []stationedPokemonDetail - stationedGmax := int64(0) - for _, stationedPokemonDetails := range stationProto.StationedPokemons { - pokemon := stationedPokemonDetails.Pokemon - display := pokemon.PokemonDisplay - stationedPokemon = append(stationedPokemon, stationedPokemonDetail{ - PokemonId: int(pokemon.PokemonId), - Form: int(display.Form), - Costume: int(display.Costume), - Gender: int(display.Gender), - Shiny: display.Shiny, - TempEvolution: int(display.CurrentTempEvolution), - TempEvolutionFinishMs: display.TemporaryEvolutionFinishMs, - Alignment: int(display.Alignment), - Badge: int(display.PokemonBadge), - Background: util.ExtractBackgroundFromDisplay(display), - BreadMode: int(display.BreadModeEnum), - }) - if display.BreadModeEnum == pogo.BreadModeEnum_BREAD_DOUGH_MODE || display.BreadModeEnum == pogo.BreadModeEnum_BREAD_DOUGH_MODE_2 { - stationedGmax++ +func (station *Station) SetBattleLevel(v null.Int) { + if station.BattleLevel != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattleLevel:%s->%s", FormatNull(station.BattleLevel), FormatNull(v))) } + station.BattleLevel = v + station.dirty = true } - jsonString, _ := json.Marshal(stationedPokemon) - station.StationedPokemon = null.StringFrom(string(jsonString)) - station.TotalStationedPokemon = null.IntFrom(int64(stationProto.TotalNumStationedPokemon)) - station.TotalStationedGmax = null.IntFrom(stationedGmax) - return station -} - -func (station *Station) resetStationedPokemonFromStationDetailsNotFound() *Station { - jsonString, _ := json.Marshal([]string{}) - station.StationedPokemon = null.StringFrom(string(jsonString)) - station.TotalStationedPokemon = null.IntFrom(0) - station.TotalStationedGmax = null.IntFrom(0) - return station -} - -func ResetStationedPokemonWithStationDetailsNotFound(ctx context.Context, db db.DbDetails, request *pogo.GetStationedPokemonDetailsProto) string { - stationId := request.StationId - stationMutex, _ := stationStripedMutex.GetLock(stationId) - stationMutex.Lock() - defer stationMutex.Unlock() - - station, err := getStationRecord(ctx, db, stationId) - if err != nil { - log.Printf("Get station %s", err) - return "Error getting station" +} + +func (station *Station) SetBattleStart(v null.Int) { + if station.BattleStart != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattleStart:%s->%s", FormatNull(station.BattleStart), FormatNull(v))) + } + station.BattleStart = v + station.dirty = true } +} - if station == nil { - log.Infof("Stationed pokemon details for station %s not found", stationId) - return fmt.Sprintf("Stationed pokemon details for station %s not found", stationId) +func (station *Station) SetBattleEnd(v null.Int) { + if station.BattleEnd != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattleEnd:%s->%s", FormatNull(station.BattleEnd), FormatNull(v))) + } + station.BattleEnd = v + station.dirty = true } +} - station.resetStationedPokemonFromStationDetailsNotFound() - saveStationRecord(ctx, db, station) - return fmt.Sprintf("StationedPokemonDetails %s", stationId) +func (station *Station) SetBattlePokemonId(v null.Int) { + if station.BattlePokemonId != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonId:%s->%s", FormatNull(station.BattlePokemonId), FormatNull(v))) + } + station.BattlePokemonId = v + station.dirty = true + } +} + +func (station *Station) SetBattlePokemonForm(v null.Int) { + if station.BattlePokemonForm != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonForm:%s->%s", FormatNull(station.BattlePokemonForm), FormatNull(v))) + } + station.BattlePokemonForm = v + station.dirty = true + } } -func UpdateStationWithStationDetails(ctx context.Context, db db.DbDetails, request *pogo.GetStationedPokemonDetailsProto, stationDetails *pogo.GetStationedPokemonDetailsOutProto) string { - stationId := request.StationId - stationMutex, _ := stationStripedMutex.GetLock(stationId) - stationMutex.Lock() - defer stationMutex.Unlock() +func (station *Station) SetBattlePokemonCostume(v null.Int) { + if station.BattlePokemonCostume != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonCostume:%s->%s", FormatNull(station.BattlePokemonCostume), FormatNull(v))) + } + station.BattlePokemonCostume = v + station.dirty = true + } +} - station, err := getStationRecord(ctx, db, stationId) - if err != nil { - log.Printf("Get station %s", err) - return "Error getting station" +func (station *Station) SetBattlePokemonGender(v null.Int) { + if station.BattlePokemonGender != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonGender:%s->%s", FormatNull(station.BattlePokemonGender), FormatNull(v))) + } + station.BattlePokemonGender = v + station.dirty = true } +} - if station == nil { - log.Infof("Stationed pokemon details for station %s not found", stationId) - return fmt.Sprintf("Stationed pokemon details for station %s not found", stationId) +func (station *Station) SetBattlePokemonAlignment(v null.Int) { + if station.BattlePokemonAlignment != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonAlignment:%s->%s", FormatNull(station.BattlePokemonAlignment), FormatNull(v))) + } + station.BattlePokemonAlignment = v + station.dirty = true } +} + +func (station *Station) SetBattlePokemonBreadMode(v null.Int) { + if station.BattlePokemonBreadMode != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonBreadMode:%s->%s", FormatNull(station.BattlePokemonBreadMode), FormatNull(v))) + } + station.BattlePokemonBreadMode = v + station.dirty = true + } +} + +func (station *Station) SetBattlePokemonMove1(v null.Int) { + if station.BattlePokemonMove1 != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonMove1:%s->%s", FormatNull(station.BattlePokemonMove1), FormatNull(v))) + } + station.BattlePokemonMove1 = v + station.dirty = true + } +} + +func (station *Station) SetBattlePokemonMove2(v null.Int) { + if station.BattlePokemonMove2 != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonMove2:%s->%s", FormatNull(station.BattlePokemonMove2), FormatNull(v))) + } + station.BattlePokemonMove2 = v + station.dirty = true + } +} + +func (station *Station) SetBattlePokemonStamina(v null.Int) { + if station.BattlePokemonStamina != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonStamina:%s->%s", FormatNull(station.BattlePokemonStamina), FormatNull(v))) + } + station.BattlePokemonStamina = v + station.dirty = true + } +} + +func (station *Station) SetBattlePokemonCpMultiplier(v null.Float) { + if !nullFloatAlmostEqual(station.BattlePokemonCpMultiplier, v, floatTolerance) { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("BattlePokemonCpMultiplier:%s->%s", FormatNull(station.BattlePokemonCpMultiplier), FormatNull(v))) + } + station.BattlePokemonCpMultiplier = v + station.dirty = true + } +} + +func (station *Station) SetTotalStationedPokemon(v null.Int) { + if station.TotalStationedPokemon != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("TotalStationedPokemon:%s->%s", FormatNull(station.TotalStationedPokemon), FormatNull(v))) + } + station.TotalStationedPokemon = v + station.dirty = true + } +} + +func (station *Station) SetTotalStationedGmax(v null.Int) { + if station.TotalStationedGmax != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("TotalStationedGmax:%s->%s", FormatNull(station.TotalStationedGmax), FormatNull(v))) + } + station.TotalStationedGmax = v + station.dirty = true + } +} + +func (station *Station) SetStationedPokemon(v null.String) { + if station.StationedPokemon != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("StationedPokemon:%s->%s", FormatNull(station.StationedPokemon), FormatNull(v))) + } + station.StationedPokemon = v + station.dirty = true + } +} - station.updateFromGetStationedPokemonDetailsOutProto(stationDetails) - saveStationRecord(ctx, db, station) - return fmt.Sprintf("StationedPokemonDetails %s", stationId) -} - -func createStationWebhooks(oldStation *Station, station *Station) { - if oldStation == nil || station.BattlePokemonId.Valid && (oldStation.EndTime != station.EndTime || - oldStation.BattleEnd != station.BattleEnd || - oldStation.BattlePokemonId != station.BattlePokemonId || - oldStation.BattlePokemonForm != station.BattlePokemonForm || - oldStation.BattlePokemonCostume != station.BattlePokemonCostume || - oldStation.BattlePokemonGender != station.BattlePokemonGender || - oldStation.BattlePokemonBreadMode != station.BattlePokemonBreadMode) { - stationHook := map[string]any{ - "id": station.Id, - "latitude": station.Lat, - "longitude": station.Lon, - "name": station.Name, - "start_time": station.StartTime, - "end_time": station.EndTime, - "is_battle_available": station.IsBattleAvailable, - "battle_level": station.BattleLevel, - "battle_start": station.BattleStart, - "battle_end": station.BattleEnd, - "battle_pokemon_id": station.BattlePokemonId, - "battle_pokemon_form": station.BattlePokemonForm, - "battle_pokemon_costume": station.BattlePokemonCostume, - "battle_pokemon_gender": station.BattlePokemonGender, - "battle_pokemon_alignment": station.BattlePokemonAlignment, - "battle_pokemon_bread_mode": station.BattlePokemonBreadMode, - "battle_pokemon_move_1": station.BattlePokemonMove1, - "battle_pokemon_move_2": station.BattlePokemonMove2, - "total_stationed_pokemon": station.TotalStationedPokemon, - "total_stationed_gmax": station.TotalStationedGmax, - "updated": station.Updated, +func (station *Station) SetUpdated(v int64) { + if station.Updated != v { + if dbDebugEnabled { + station.changedFields = append(station.changedFields, fmt.Sprintf("Updated:%d->%d", station.Updated, v)) } - areas := MatchStatsGeofence(station.Lat, station.Lon) - webhooksSender.AddMessage(webhooks.MaxBattle, stationHook, areas) - statsCollector.UpdateMaxBattleCount(areas, station.BattleLevel.ValueOrZero()) + station.Updated = v + station.dirty = true } } diff --git a/decoder/station_decode.go b/decoder/station_decode.go new file mode 100644 index 00000000..da341b9a --- /dev/null +++ b/decoder/station_decode.go @@ -0,0 +1,106 @@ +package decoder + +import ( + "encoding/json" + + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/pogo" + "golbat/util" +) + +func (station *Station) updateFromStationProto(stationProto *pogo.StationProto, cellId uint64) *Station { + station.SetId(stationProto.Id) + name := stationProto.Name + // NOTE: Some names have more than 255 runes, which won't fit in our + // varchar(255). + if truncateStr, truncated := util.TruncateUTF8(stationProto.Name, 255); truncated { + log.Warnf("truncating name for station id '%s'. Orig name: %s", + stationProto.Id, + stationProto.Name, + ) + name = truncateStr + } + station.SetName(name) + station.SetLat(stationProto.Lat) + station.SetLon(stationProto.Lng) + station.SetStartTime(stationProto.StartTimeMs / 1000) + station.SetEndTime(stationProto.EndTimeMs / 1000) + station.SetCooldownComplete(stationProto.CooldownCompleteMs) + station.SetIsBattleAvailable(stationProto.IsBreadBattleAvailable) + if battleDetails := stationProto.BattleDetails; battleDetails != nil { + station.SetBattleLevel(null.IntFrom(int64(battleDetails.BattleLevel))) + station.SetBattleStart(null.IntFrom(battleDetails.BattleWindowStartMs / 1000)) + station.SetBattleEnd(null.IntFrom(battleDetails.BattleWindowEndMs / 1000)) + if pokemon := battleDetails.BattlePokemon; pokemon != nil { + station.SetBattlePokemonId(null.IntFrom(int64(pokemon.PokemonId))) + station.SetBattlePokemonMove1(null.IntFrom(int64(pokemon.Move1))) + station.SetBattlePokemonMove2(null.IntFrom(int64(pokemon.Move2))) + station.SetBattlePokemonForm(null.IntFrom(int64(pokemon.PokemonDisplay.Form))) + station.SetBattlePokemonCostume(null.IntFrom(int64(pokemon.PokemonDisplay.Costume))) + station.SetBattlePokemonGender(null.IntFrom(int64(pokemon.PokemonDisplay.Gender))) + station.SetBattlePokemonAlignment(null.IntFrom(int64(pokemon.PokemonDisplay.Alignment))) + station.SetBattlePokemonBreadMode(null.IntFrom(int64(pokemon.PokemonDisplay.BreadModeEnum))) + station.SetBattlePokemonStamina(null.IntFrom(int64(pokemon.Stamina))) + station.SetBattlePokemonCpMultiplier(null.FloatFrom(float64(pokemon.CpMultiplier))) + if rewardPokemon := battleDetails.RewardPokemon; rewardPokemon != nil && pokemon.PokemonId != rewardPokemon.PokemonId { + log.Infof("[DYNAMAX] Pokemon reward differs from battle: Battle %v - Reward %v", pokemon, rewardPokemon) + } + } + } + station.SetCellId(int64(cellId)) + return station +} + +func (station *Station) updateFromGetStationedPokemonDetailsOutProto(stationProto *pogo.GetStationedPokemonDetailsOutProto) *Station { + type stationedPokemonDetail struct { + PokemonId int `json:"pokemon_id"` + Form int `json:"form"` + Costume int `json:"costume"` + Gender int `json:"gender"` + Shiny bool `json:"shiny,omitempty"` + TempEvolution int `json:"temp_evolution,omitempty"` + TempEvolutionFinishMs int64 `json:"temp_evolution_finish_ms,omitempty"` + Alignment int `json:"alignment,omitempty"` + Badge int `json:"badge,omitempty"` + Background *int64 `json:"background,omitempty"` + BreadMode int `json:"bread_mode"` + } + + var stationedPokemon []stationedPokemonDetail + stationedGmax := int64(0) + for _, stationedPokemonDetails := range stationProto.StationedPokemons { + pokemon := stationedPokemonDetails.Pokemon + display := pokemon.PokemonDisplay + stationedPokemon = append(stationedPokemon, stationedPokemonDetail{ + PokemonId: int(pokemon.PokemonId), + Form: int(display.Form), + Costume: int(display.Costume), + Gender: int(display.Gender), + Shiny: display.Shiny, + TempEvolution: int(display.CurrentTempEvolution), + TempEvolutionFinishMs: display.TemporaryEvolutionFinishMs, + Alignment: int(display.Alignment), + Badge: int(display.PokemonBadge), + Background: util.ExtractBackgroundFromDisplay(display), + BreadMode: int(display.BreadModeEnum), + }) + if display.BreadModeEnum == pogo.BreadModeEnum_BREAD_DOUGH_MODE || display.BreadModeEnum == pogo.BreadModeEnum_BREAD_DOUGH_MODE_2 { + stationedGmax++ + } + } + jsonString, _ := json.Marshal(stationedPokemon) + station.SetStationedPokemon(null.StringFrom(string(jsonString))) + station.SetTotalStationedPokemon(null.IntFrom(int64(stationProto.TotalNumStationedPokemon))) + station.SetTotalStationedGmax(null.IntFrom(stationedGmax)) + return station +} + +func (station *Station) resetStationedPokemonFromStationDetailsNotFound() *Station { + jsonString, _ := json.Marshal([]string{}) + station.SetStationedPokemon(null.StringFrom(string(jsonString))) + station.SetTotalStationedPokemon(null.IntFrom(0)) + station.SetTotalStationedGmax(null.IntFrom(0)) + return station +} diff --git a/decoder/station_process.go b/decoder/station_process.go new file mode 100644 index 00000000..05cda289 --- /dev/null +++ b/decoder/station_process.go @@ -0,0 +1,51 @@ +package decoder + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func ResetStationedPokemonWithStationDetailsNotFound(ctx context.Context, db db.DbDetails, request *pogo.GetStationedPokemonDetailsProto) string { + stationId := request.StationId + + station, unlock, err := getStationRecordForUpdate(ctx, db, stationId) + if err != nil { + log.Printf("Get station %s", err) + return "Error getting station" + } + + if station == nil { + log.Infof("Stationed pokemon details for station %s not found", stationId) + return fmt.Sprintf("Stationed pokemon details for station %s not found", stationId) + } + defer unlock() + + station.resetStationedPokemonFromStationDetailsNotFound() + saveStationRecord(ctx, db, station) + return fmt.Sprintf("StationedPokemonDetails %s", stationId) +} + +func UpdateStationWithStationDetails(ctx context.Context, db db.DbDetails, request *pogo.GetStationedPokemonDetailsProto, stationDetails *pogo.GetStationedPokemonDetailsOutProto) string { + stationId := request.StationId + + station, unlock, err := getStationRecordForUpdate(ctx, db, stationId) + if err != nil { + log.Printf("Get station %s", err) + return "Error getting station" + } + + if station == nil { + log.Infof("Stationed pokemon details for station %s not found", stationId) + return fmt.Sprintf("Stationed pokemon details for station %s not found", stationId) + } + defer unlock() + + station.updateFromGetStationedPokemonDetailsOutProto(stationDetails) + saveStationRecord(ctx, db, station) + return fmt.Sprintf("StationedPokemonDetails %s", stationId) +} diff --git a/decoder/station_state.go b/decoder/station_state.go new file mode 100644 index 00000000..b038936d --- /dev/null +++ b/decoder/station_state.go @@ -0,0 +1,253 @@ +package decoder + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/guregu/null/v6" + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/webhooks" +) + +type StationWebhook struct { + Id string `json:"id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + IsBattleAvailable bool `json:"is_battle_available"` + BattleLevel null.Int `json:"battle_level"` + BattleStart null.Int `json:"battle_start"` + BattleEnd null.Int `json:"battle_end"` + BattlePokemonId null.Int `json:"battle_pokemon_id"` + BattlePokemonForm null.Int `json:"battle_pokemon_form"` + BattlePokemonCostume null.Int `json:"battle_pokemon_costume"` + BattlePokemonGender null.Int `json:"battle_pokemon_gender"` + BattlePokemonAlignment null.Int `json:"battle_pokemon_alignment"` + BattlePokemonBreadMode null.Int `json:"battle_pokemon_bread_mode"` + BattlePokemonMove1 null.Int `json:"battle_pokemon_move_1"` + BattlePokemonMove2 null.Int `json:"battle_pokemon_move_2"` + TotalStationedPokemon null.Int `json:"total_stationed_pokemon"` + TotalStationedGmax null.Int `json:"total_stationed_gmax"` + Updated int64 `json:"updated"` +} + +func loadStationFromDatabase(ctx context.Context, db db.DbDetails, stationId string, station *Station) error { + err := db.GeneralDb.GetContext(ctx, station, + `SELECT id, lat, lon, name, cell_id, start_time, end_time, cooldown_complete, is_battle_available, is_inactive, updated, battle_level, battle_start, battle_end, battle_pokemon_id, battle_pokemon_form, battle_pokemon_costume, battle_pokemon_gender, battle_pokemon_alignment, battle_pokemon_bread_mode, battle_pokemon_move_1, battle_pokemon_move_2, battle_pokemon_stamina, battle_pokemon_cp_multiplier, total_stationed_pokemon, total_stationed_gmax, stationed_pokemon + FROM station WHERE id = ?`, stationId) + statsCollector.IncDbQuery("select station", err) + return err +} + +// peekStationRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func peekStationRecord(stationId string) (*Station, func(), error) { + if item := stationCache.Get(stationId); item != nil { + station := item.Value() + station.Lock() + return station, func() { station.Unlock() }, nil + } + return nil, nil, nil +} + +// getStationRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getStationRecordReadOnly(ctx context.Context, db db.DbDetails, stationId string) (*Station, func(), error) { + // Check cache first + if item := stationCache.Get(stationId); item != nil { + station := item.Value() + station.Lock() + return station, func() { station.Unlock() }, nil + } + + dbStation := Station{} + err := loadStationFromDatabase(ctx, db, stationId, &dbStation) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbStation.ClearDirty() + + // Atomically cache the loaded Station - if another goroutine raced us, + // we'll get their Station and use that instead (ensuring same mutex) + existingStation, _ := stationCache.GetOrSetFunc(stationId, func() *Station { + return &dbStation + }) + + station := existingStation.Value() + station.Lock() + return station, func() { station.Unlock() }, nil +} + +// getStationRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Caller MUST call returned unlock function if non-nil. +func getStationRecordForUpdate(ctx context.Context, db db.DbDetails, stationId string) (*Station, func(), error) { + station, unlock, err := getStationRecordReadOnly(ctx, db, stationId) + if err != nil || station == nil { + return nil, nil, err + } + station.snapshotOldValues() + return station, unlock, nil +} + +// getOrCreateStationRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreateStationRecord(ctx context.Context, db db.DbDetails, stationId string) (*Station, func(), error) { + // Create new Station atomically - function only called if key doesn't exist + stationItem, _ := stationCache.GetOrSetFunc(stationId, func() *Station { + return &Station{Id: stationId, newRecord: true} + }) + + station := stationItem.Value() + station.Lock() + + if station.newRecord { + // We should attempt to load from database + err := loadStationFromDatabase(ctx, db, stationId, station) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + station.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + station.newRecord = false + station.ClearDirty() + } + } + + station.snapshotOldValues() + return station, func() { station.Unlock() }, nil +} + +func saveStationRecord(ctx context.Context, db db.DbDetails, station *Station) { + now := time.Now().Unix() + + // Skip save if not dirty and was updated recently (15-min debounce) + if !station.IsDirty() && !station.IsNewRecord() { + if station.Updated > now-GetUpdateThreshold(900) { + return + } + } + + station.SetUpdated(now) + + if station.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Station", station.Id, station.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, + ` + INSERT INTO station (id, lat, lon, name, cell_id, start_time, end_time, cooldown_complete, is_battle_available, is_inactive, updated, battle_level, battle_start, battle_end, battle_pokemon_id, battle_pokemon_form, battle_pokemon_costume, battle_pokemon_gender, battle_pokemon_alignment, battle_pokemon_bread_mode, battle_pokemon_move_1, battle_pokemon_move_2, battle_pokemon_stamina, battle_pokemon_cp_multiplier, total_stationed_pokemon, total_stationed_gmax, stationed_pokemon) + VALUES (:id,:lat,:lon,:name,:cell_id,:start_time,:end_time,:cooldown_complete,:is_battle_available,:is_inactive,:updated,:battle_level,:battle_start,:battle_end,:battle_pokemon_id,:battle_pokemon_form,:battle_pokemon_costume,:battle_pokemon_gender,:battle_pokemon_alignment,:battle_pokemon_bread_mode,:battle_pokemon_move_1,:battle_pokemon_move_2,:battle_pokemon_stamina,:battle_pokemon_cp_multiplier,:total_stationed_pokemon,:total_stationed_gmax,:stationed_pokemon) + `, station) + + statsCollector.IncDbQuery("insert station", err) + if err != nil { + log.Errorf("insert station: %s", err) + return + } + _, _ = res, err + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Station", station.Id, station.changedFields) + } + res, err := db.GeneralDb.NamedExecContext(ctx, ` + UPDATE station + SET + lat = :lat, + lon = :lon, + name = :name, + cell_id = :cell_id, + start_time = :start_time, + end_time = :end_time, + cooldown_complete = :cooldown_complete, + is_battle_available = :is_battle_available, + is_inactive = :is_inactive, + updated = :updated, + battle_level = :battle_level, + battle_start = :battle_start, + battle_end = :battle_end, + battle_pokemon_id = :battle_pokemon_id, + battle_pokemon_form = :battle_pokemon_form, + battle_pokemon_costume = :battle_pokemon_costume, + battle_pokemon_gender = :battle_pokemon_gender, + battle_pokemon_alignment = :battle_pokemon_alignment, + battle_pokemon_bread_mode = :battle_pokemon_bread_mode, + battle_pokemon_move_1 = :battle_pokemon_move_1, + battle_pokemon_move_2 = :battle_pokemon_move_2, + battle_pokemon_stamina = :battle_pokemon_stamina, + battle_pokemon_cp_multiplier = :battle_pokemon_cp_multiplier, + total_stationed_pokemon = :total_stationed_pokemon, + total_stationed_gmax = :total_stationed_gmax, + stationed_pokemon = :stationed_pokemon + WHERE id = :id + `, station, + ) + statsCollector.IncDbQuery("update station", err) + if err != nil { + log.Errorf("Update station %s", err) + } + _, _ = res, err + } + + if dbDebugEnabled { + station.changedFields = station.changedFields[:0] + } + station.ClearDirty() + createStationWebhooks(station) + if station.IsNewRecord() { + stationCache.Set(station.Id, station, ttlcache.DefaultTTL) + station.newRecord = false + } +} + +func createStationWebhooks(station *Station) { + old := &station.oldValues + isNew := station.IsNewRecord() + + if isNew || station.BattlePokemonId.Valid && (old.EndTime != station.EndTime || + old.BattleEnd != station.BattleEnd || + old.BattlePokemonId != station.BattlePokemonId || + old.BattlePokemonForm != station.BattlePokemonForm || + old.BattlePokemonCostume != station.BattlePokemonCostume || + old.BattlePokemonGender != station.BattlePokemonGender || + old.BattlePokemonBreadMode != station.BattlePokemonBreadMode) { + stationHook := StationWebhook{ + Id: station.Id, + Latitude: station.Lat, + Longitude: station.Lon, + Name: station.Name, + StartTime: station.StartTime, + EndTime: station.EndTime, + IsBattleAvailable: station.IsBattleAvailable, + BattleLevel: station.BattleLevel, + BattleStart: station.BattleStart, + BattleEnd: station.BattleEnd, + BattlePokemonId: station.BattlePokemonId, + BattlePokemonForm: station.BattlePokemonForm, + BattlePokemonCostume: station.BattlePokemonCostume, + BattlePokemonGender: station.BattlePokemonGender, + BattlePokemonAlignment: station.BattlePokemonAlignment, + BattlePokemonBreadMode: station.BattlePokemonBreadMode, + BattlePokemonMove1: station.BattlePokemonMove1, + BattlePokemonMove2: station.BattlePokemonMove2, + TotalStationedPokemon: station.TotalStationedPokemon, + TotalStationedGmax: station.TotalStationedGmax, + Updated: station.Updated, + } + areas := MatchStatsGeofence(station.Lat, station.Lon) + webhooksSender.AddMessage(webhooks.MaxBattle, stationHook, areas) + statsCollector.UpdateMaxBattleCount(areas, station.BattleLevel.ValueOrZero()) + } +} diff --git a/decoder/stats.go b/decoder/stats.go index 86ea7956..d5312bf9 100644 --- a/decoder/stats.go +++ b/decoder/stats.go @@ -241,7 +241,7 @@ func updateEncounterStats(pokemon *Pokemon) { } } -func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now int64) { +func updatePokemonStats(pokemon *Pokemon, areas []geo.AreaName, now int64) { if len(areas) == 0 { areas = []geo.AreaName{ { @@ -273,15 +273,12 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in populateEncounterCacheVal := func() { if encounterCacheVal == nil { - encounterCacheVal = encounterCache.GetOrCreate(new.Id) + encounterCacheVal = encounterCache.GetOrCreate(pokemon.Id) } } - currentSeenType := new.SeenType.ValueOrZero() - oldSeenType := "" - if old != nil { - oldSeenType = old.SeenType.ValueOrZero() - } + currentSeenType := pokemon.SeenType.ValueOrZero() + oldSeenType := pokemon.oldValues.SeenType.ValueOrZero() if currentSeenType != oldSeenType { if oldSeenType == "" || oldSeenType == SeenType_NearbyStop || oldSeenType == SeenType_Cell { @@ -291,7 +288,7 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in // transition to wild for the first time.. populateEncounterCacheVal() encounterCacheVal.FirstEncounter = 0 - encounterCacheVal.FirstWild = new.Updated.ValueOrZero() + encounterCacheVal.FirstWild = pokemon.Updated.ValueOrZero() // This will be put into the cache later. } @@ -305,7 +302,7 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in populateEncounterCacheVal() if encounterCacheVal.FirstEncounter == 0 { // This is first encounter - encounterCacheVal.FirstEncounter = new.Updated.ValueOrZero() + encounterCacheVal.FirstEncounter = pokemon.Updated.ValueOrZero() if encounterCacheVal.FirstWild > 0 { timeToEncounter = encounterCacheVal.FirstEncounter - encounterCacheVal.FirstWild @@ -313,8 +310,8 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in monsIvIncr = 1 - if new.ExpireTimestampVerified { - tth := new.ExpireTimestamp.ValueOrZero() - new.Updated.ValueOrZero() // relies on Updated being set + if pokemon.ExpireTimestampVerified { + tth := pokemon.ExpireTimestamp.ValueOrZero() - pokemon.Updated.ValueOrZero() // relies on Updated being set bucket = tth / (5 * 60) if bucket > 11 { bucket = 11 @@ -325,8 +322,8 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in unverifiedEncIncr = 1 } } else { - if new.ExpireTimestampVerified { - tth := new.ExpireTimestamp.ValueOrZero() - new.Updated.ValueOrZero() // relies on Updated being set + if pokemon.ExpireTimestampVerified { + tth := pokemon.ExpireTimestamp.ValueOrZero() - pokemon.Updated.ValueOrZero() // relies on Updated being set verifiedReEncounterIncr = 1 verifiedReEncSecTotalIncr = tth @@ -337,12 +334,12 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in // If we have a cache entry, it means we updated it. So now let's store it. if encounterCacheVal != nil { - encounterCache.Put(new.Id, encounterCacheVal, new.remainingDuration(now)) + encounterCache.Put(pokemon.Id, encounterCacheVal, pokemon.remainingDuration(now)) } if (currentSeenType == SeenType_Wild && oldSeenType == SeenType_Encounter) || (currentSeenType == SeenType_Encounter && oldSeenType == SeenType_Encounter && - new.PokemonId != old.PokemonId) { + pokemon.PokemonId != pokemon.oldValues.PokemonId) { // stats reset statsResetCountIncr = 1 } @@ -352,10 +349,10 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in var isHundo bool var isNundo bool - if new.Cp.Valid && new.AtkIv.Valid && new.DefIv.Valid && new.StaIv.Valid { - atk := new.AtkIv.ValueOrZero() - def := new.DefIv.ValueOrZero() - sta := new.StaIv.ValueOrZero() + if pokemon.Cp.Valid && pokemon.AtkIv.Valid && pokemon.DefIv.Valid && pokemon.StaIv.Valid { + atk := pokemon.AtkIv.ValueOrZero() + def := pokemon.DefIv.ValueOrZero() + sta := pokemon.StaIv.ValueOrZero() if atk == 15 && def == 15 && sta == 15 { isHundo = true } else if atk == 0 && def == 0 && sta == 0 { @@ -369,7 +366,7 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in // Count stats - if old == nil || old.Cp != new.Cp { // pokemon is new or CP has changed (encountered or re-encountered) + if pokemon.isNewRecord() || pokemon.oldValues.Cp != pokemon.Cp { // pokemon is new or CP has changed (encountered or re-encountered) if !locked { pokemonStatsLock.Lock() locked = true @@ -387,18 +384,18 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in pokemonCount[area] = countStats } - formId := int(new.Form.ValueOrZero()) - pf := pokemonForm{pokemonId: new.PokemonId, formId: formId} + formId := int(pokemon.Form.ValueOrZero()) + pf := pokemonForm{pokemonId: pokemon.PokemonId, formId: formId} - if old == nil || old.PokemonId != new.PokemonId { // pokemon is new or type has changed + if pokemon.isNewRecord() || pokemon.oldValues.PokemonId != pokemon.PokemonId { // pokemon is new or type has changed countStats.count[pf]++ statsCollector.IncPokemonCountNew(fullAreaName) - if new.ExpireTimestampVerified { - statsCollector.UpdateVerifiedTtl(area, new.SeenType, new.ExpireTimestamp) + if pokemon.ExpireTimestampVerified { + statsCollector.UpdateVerifiedTtl(area, pokemon.SeenType, pokemon.ExpireTimestamp) } } - if new.Cp.Valid { + if pokemon.Cp.Valid { countStats.ivCount[pf]++ statsCollector.IncPokemonCountIv(fullAreaName) if isHundo { @@ -448,7 +445,7 @@ func updatePokemonStats(old *Pokemon, new *Pokemon, areas []geo.AreaName, now in } } -func updateRaidStats(old *Gym, new *Gym, areas []geo.AreaName) { +func updateRaidStats(gym *Gym, areas []geo.AreaName) { if len(areas) == 0 { areas = []geo.AreaName{{Parent: "unmatched", Name: "unmatched"}} } @@ -459,8 +456,8 @@ func updateRaidStats(old *Gym, new *Gym, areas []geo.AreaName) { for i := 0; i < len(areas); i++ { area := areas[i] - if new.RaidPokemonId.ValueOrZero() > 0 && - (old == nil || old.RaidPokemonId != new.RaidPokemonId || old.RaidEndTimestamp != new.RaidEndTimestamp) { + if gym.RaidPokemonId.ValueOrZero() > 0 && + (gym.newRecord || gym.oldValues.RaidPokemonId != gym.RaidPokemonId || gym.oldValues.RaidSpawnTimestamp != gym.RaidSpawnTimestamp) { if !locked { raidStatsLock.Lock() @@ -471,13 +468,13 @@ func updateRaidStats(old *Gym, new *Gym, areas []geo.AreaName) { raidCount[area] = make(map[int64]*areaRaidCountDetail) } countStats := raidCount[area] - raidLevel := new.RaidLevel.ValueOrZero() + raidLevel := gym.RaidLevel.ValueOrZero() if countStats[raidLevel] == nil { countStats[raidLevel] = &areaRaidCountDetail{count: make(map[pokemonForm]int)} } pf := pokemonForm{ - pokemonId: int16(new.RaidPokemonId.ValueOrZero()), - formId: int(new.RaidPokemonForm.ValueOrZero()), + pokemonId: int16(gym.RaidPokemonId.ValueOrZero()), + formId: int(gym.RaidPokemonForm.ValueOrZero()), } countStats[raidLevel].count[pf]++ } @@ -488,7 +485,7 @@ func updateRaidStats(old *Gym, new *Gym, areas []geo.AreaName) { } } -func updateIncidentStats(old *Incident, new *Incident, areas []geo.AreaName) { +func updateIncidentStats(incident *Incident, areas []geo.AreaName) { if len(areas) == 0 { areas = []geo.AreaName{ { @@ -504,13 +501,15 @@ func updateIncidentStats(old *Incident, new *Incident, areas []geo.AreaName) { }) locked := false + old := &incident.oldValues + isNew := incident.IsNewRecord() // Loop though all areas for i := 0; i < len(areas); i++ { area := areas[i] // Check if StartTime has changed, then we can assume a new Incident has appeared. - if old == nil || old.StartTime != new.StartTime { + if isNew || old.StartTime != incident.StartTime { if !locked { incidentStatsLock.Lock() @@ -524,8 +523,8 @@ func updateIncidentStats(old *Incident, new *Incident, areas []geo.AreaName) { } // Exclude Kecleon, Showcases and other UNSET characters for invasionStats. - if new.Character != 0 { - invasionStats.count[new.Character]++ + if incident.Character != 0 { + invasionStats.count[incident.Character]++ } } } diff --git a/decoder/tappable.go b/decoder/tappable.go index bbca89a4..20ff851d 100644 --- a/decoder/tappable.go +++ b/decoder/tappable.go @@ -1,235 +1,168 @@ package decoder import ( - "context" - "database/sql" - "errors" "fmt" - "golbat/db" - "golbat/pogo" - "strconv" - "time" - - "github.com/jellydator/ttlcache/v3" - log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" + "sync" + + "github.com/guregu/null/v6" ) +// Tappable struct. +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Tappable struct { - Id uint64 `db:"id" json:"id"` - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - FortId null.String `db:"fort_id" json:"fort_id"` // either fortId or spawnpointId are given - SpawnId null.Int `db:"spawn_id" json:"spawn_id"` - Type string `db:"type" json:"type"` - Encounter null.Int `db:"pokemon_id" json:"pokemon_id"` - ItemId null.Int `db:"item_id" json:"item_id"` - Count null.Int `db:"count" json:"count"` - ExpireTimestamp null.Int `db:"expire_timestamp" json:"expire_timestamp"` - ExpireTimestampVerified bool `db:"expire_timestamp_verified" json:"expire_timestamp_verified"` - Updated int64 `db:"updated" json:"updated"` -} - -func (ta *Tappable) updateFromProcessTappableProto(ctx context.Context, db db.DbDetails, tappable *pogo.ProcessTappableOutProto, request *pogo.ProcessTappableProto, timestampMs int64) { - // update from request - ta.Id = request.EncounterId - location := request.GetLocation() - if spawnPointId := location.GetSpawnpointId(); spawnPointId != "" { - spawnId, err := strconv.ParseInt(spawnPointId, 16, 64) - if err != nil { - panic(err) + mu sync.Mutex `db:"-"` // Object-level mutex + + Id uint64 `db:"id"` + Lat float64 `db:"lat"` + Lon float64 `db:"lon"` + FortId null.String `db:"fort_id"` // either fortId or spawnpointId are given + SpawnId null.Int `db:"spawn_id"` + Type string `db:"type"` + Encounter null.Int `db:"pokemon_id"` + ItemId null.Int `db:"item_id"` + Count null.Int `db:"count"` + ExpireTimestamp null.Int `db:"expire_timestamp"` + ExpireTimestampVerified bool `db:"expire_timestamp_verified"` + Updated int64 `db:"updated"` + + dirty bool `db:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-"` // Not persisted - tracks if this is a new record + changedFields []string `db:"-"` // Track which fields changed (only when dbDebugEnabled) +} + +// IsDirty returns true if any field has been modified +func (ta *Tappable) IsDirty() bool { + return ta.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (ta *Tappable) ClearDirty() { + ta.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (ta *Tappable) IsNewRecord() bool { + return ta.newRecord +} + +// Lock acquires the Tappable's mutex +func (ta *Tappable) Lock() { + ta.mu.Lock() +} + +// Unlock releases the Tappable's mutex +func (ta *Tappable) Unlock() { + ta.mu.Unlock() +} + +// --- Set methods with dirty tracking --- + +func (ta *Tappable) SetLat(v float64) { + if !floatAlmostEqual(ta.Lat, v, floatTolerance) { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Lat:%f->%f", ta.Lat, v)) } - ta.SpawnId = null.IntFrom(spawnId) - } - if fortId := location.GetFortId(); fortId != "" { - ta.FortId = null.StringFrom(fortId) + ta.Lat = v + ta.dirty = true } - ta.Type = request.TappableTypeId - ta.Lat = request.LocationHintLat - ta.Lon = request.LocationHintLng - ta.setExpireTimestamp(ctx, db, timestampMs) - - // update from tappable - if encounter := tappable.GetEncounter(); encounter != nil { - // tappable is a Pokèmon, encounter is sent in a separate proto - // we store this to link tappable with Pokèmon from encounter proto - ta.Encounter = null.IntFrom(int64(encounter.Pokemon.PokemonId)) - } else if reward := tappable.GetReward(); reward != nil { - for _, lootProto := range reward { - for _, itemProto := range lootProto.GetLootItem() { - switch t := itemProto.Type.(type) { - case *pogo.LootItemProto_Item: - ta.ItemId = null.IntFrom(int64(t.Item)) - ta.Count = null.IntFrom(int64(itemProto.Count)) - case *pogo.LootItemProto_Stardust: - log.Warnf("[TAPPABLE] Reward is Stardust: %t", t.Stardust) - case *pogo.LootItemProto_Pokecoin: - log.Warnf("[TAPPABLE] Reward is Pokecoin: %t", t.Pokecoin) - case *pogo.LootItemProto_PokemonCandy: - log.Warnf("[TAPPABLE] Reward is Pokemon Candy: %v", t.PokemonCandy) - case *pogo.LootItemProto_Experience: - log.Warnf("[TAPPABLE] Reward is Experience: %t", t.Experience) - case *pogo.LootItemProto_PokemonEgg: - log.Warnf("[TAPPABLE] Reward is a Pokemon Egg: %v", t.PokemonEgg) - case *pogo.LootItemProto_AvatarTemplateId: - log.Warnf("[TAPPABLE] Reward is an Avatar Template ID: %v", t.AvatarTemplateId) - case *pogo.LootItemProto_StickerId: - log.Warnf("[TAPPABLE] Reward is a Sticker ID: %s", t.StickerId) - case *pogo.LootItemProto_MegaEnergyPokemonId: - log.Warnf("[TAPPABLE] Reward is Mega Energy Pokemon ID: %v", t.MegaEnergyPokemonId) - case *pogo.LootItemProto_XlCandy: - log.Warnf("[TAPPABLE] Reward is XL Candy: %v", t.XlCandy) - case *pogo.LootItemProto_FollowerPokemon: - log.Warnf("[TAPPABLE] Reward is a Follower Pokemon: %v", t.FollowerPokemon) - case *pogo.LootItemProto_NeutralAvatarTemplateId: - log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Template ID: %v", t.NeutralAvatarTemplateId) - case *pogo.LootItemProto_NeutralAvatarItemTemplate: - log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Item Template: %v", t.NeutralAvatarItemTemplate) - case *pogo.LootItemProto_NeutralAvatarItemDisplay: - log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Item Display: %v", t.NeutralAvatarItemDisplay) - default: - log.Warnf("Unknown or unset Type") - } - } +} + +func (ta *Tappable) SetLon(v float64) { + if !floatAlmostEqual(ta.Lon, v, floatTolerance) { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Lon:%f->%f", ta.Lon, v)) } + ta.Lon = v + ta.dirty = true } } -func (ta *Tappable) setExpireTimestamp(ctx context.Context, db db.DbDetails, timestampMs int64) { - ta.ExpireTimestampVerified = false - if spawnId := ta.SpawnId.ValueOrZero(); spawnId != 0 { - spawnPoint, _ := getSpawnpointRecord(ctx, db, spawnId) - if spawnPoint != nil && spawnPoint.DespawnSec.Valid { - despawnSecond := int(spawnPoint.DespawnSec.ValueOrZero()) - - date := time.Unix(timestampMs/1000, 0) - secondOfHour := date.Second() + date.Minute()*60 - - despawnOffset := despawnSecond - secondOfHour - if despawnOffset < 0 { - despawnOffset += 3600 - } - ta.ExpireTimestamp = null.IntFrom(int64(timestampMs)/1000 + int64(despawnOffset)) - ta.ExpireTimestampVerified = true - } else { - ta.setUnknownTimestamp(timestampMs / 1000) +func (ta *Tappable) SetFortId(v null.String) { + if ta.FortId != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("FortId:%s->%s", FormatNull(ta.FortId), FormatNull(v))) } - } else if fortId := ta.FortId.ValueOrZero(); fortId != "" { - // we don't know any despawn times from lured/fort tappables - ta.ExpireTimestamp = null.IntFrom(int64(timestampMs)/1000 + int64(120)) + ta.FortId = v + ta.dirty = true } } -func (ta *Tappable) setUnknownTimestamp(now int64) { - if !ta.ExpireTimestamp.Valid { - ta.ExpireTimestamp = null.IntFrom(now + 20*60) - } else { - if ta.ExpireTimestamp.Int64 < now { - ta.ExpireTimestamp = null.IntFrom(now + 10*60) +func (ta *Tappable) SetSpawnId(v null.Int) { + if ta.SpawnId != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("SpawnId:%s->%s", FormatNull(ta.SpawnId), FormatNull(v))) } + ta.SpawnId = v + ta.dirty = true } } -func GetTappableRecord(ctx context.Context, db db.DbDetails, id uint64) (*Tappable, error) { - inMemoryTappable := tappableCache.Get(id) - if inMemoryTappable != nil { - tappable := inMemoryTappable.Value() - return &tappable, nil - } - tappable := Tappable{} - err := db.GeneralDb.GetContext(ctx, &tappable, - `SELECT id, lat, lon, fort_id, spawn_id, type, pokemon_id, item_id, count, expire_timestamp, expire_timestamp_verified, updated - FROM tappable - WHERE id = ?`, strconv.FormatUint(id, 10)) - statsCollector.IncDbQuery("select tappable", err) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil +func (ta *Tappable) SetType(v string) { + if ta.Type != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Type:%s->%s", ta.Type, v)) + } + ta.Type = v + ta.dirty = true } +} - if err != nil { - return nil, err +func (ta *Tappable) SetEncounter(v null.Int) { + if ta.Encounter != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Encounter:%s->%s", FormatNull(ta.Encounter), FormatNull(v))) + } + ta.Encounter = v + ta.dirty = true } - return &tappable, nil } -func saveTappableRecord(ctx context.Context, details db.DbDetails, tappable *Tappable) { - oldTappable, _ := GetTappableRecord(ctx, details, tappable.Id) - now := time.Now().Unix() - if oldTappable != nil && !hasChangesTappable(oldTappable, tappable) { - return - } - tappable.Updated = now - if oldTappable == nil { - res, err := details.GeneralDb.NamedExecContext(ctx, fmt.Sprintf(` - INSERT INTO tappable ( - id, lat, lon, fort_id, spawn_id, type, pokemon_id, item_id, count, expire_timestamp, expire_timestamp_verified, updated - ) VALUES ( - "%d", :lat, :lon, :fort_id, :spawn_id, :type, :pokemon_id, :item_id, :count, :expire_timestamp, :expire_timestamp_verified, :updated - ) - `, tappable.Id), tappable) - statsCollector.IncDbQuery("insert tappable", err) - if err != nil { - log.Errorf("insert tappable %d: %s", tappable.Id, err) - return +func (ta *Tappable) SetItemId(v null.Int) { + if ta.ItemId != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("ItemId:%s->%s", FormatNull(ta.ItemId), FormatNull(v))) } - _ = res - } else { - res, err := details.GeneralDb.NamedExecContext(ctx, fmt.Sprintf(` - UPDATE tappable SET - lat = :lat, - lon = :lon, - fort_id = :fort_id, - spawn_id = :spawn_id, - type = :type, - pokemon_id = :pokemon_id, - item_id = :item_id, - count = :count, - expire_timestamp = :expire_timestamp, - expire_timestamp_verified = :expire_timestamp_verified, - updated = :updated - WHERE id = "%d" - `, tappable.Id), tappable) - statsCollector.IncDbQuery("update tappable", err) - if err != nil { - log.Errorf("update tappable %d: %s", tappable.Id, err) - return + ta.ItemId = v + ta.dirty = true + } +} + +func (ta *Tappable) SetCount(v null.Int) { + if ta.Count != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Count:%s->%s", FormatNull(ta.Count), FormatNull(v))) } - _ = res + ta.Count = v + ta.dirty = true } - tappableCache.Set(tappable.Id, *tappable, ttlcache.DefaultTTL) -} - -func hasChangesTappable(old *Tappable, new *Tappable) bool { - return old.Id != new.Id || - old.FortId != new.FortId || - old.SpawnId != new.SpawnId || - old.Type != new.Type || - old.Encounter != new.Encounter || - old.ItemId != new.ItemId || - old.Count != new.Count || - old.ExpireTimestamp != new.ExpireTimestamp || - old.ExpireTimestampVerified != new.ExpireTimestampVerified || - !floatAlmostEqual(old.Lat, new.Lat, floatTolerance) || - !floatAlmostEqual(old.Lon, new.Lon, floatTolerance) -} - -func UpdateTappable(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, tappableDetails *pogo.ProcessTappableOutProto, timestampMs int64) string { - id := request.GetEncounterId() - tappableMutex, _ := tappableStripedMutex.GetLock(id) - tappableMutex.Lock() - defer tappableMutex.Unlock() - - tappable, err := GetTappableRecord(ctx, db, id) - if err != nil { - log.Printf("Get tappable %s", err) - return "Error getting tappable" +} + +func (ta *Tappable) SetExpireTimestamp(v null.Int) { + if ta.ExpireTimestamp != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("ExpireTimestamp:%s->%s", FormatNull(ta.ExpireTimestamp), FormatNull(v))) + } + ta.ExpireTimestamp = v + ta.dirty = true } +} - if tappable == nil { - tappable = &Tappable{} +func (ta *Tappable) SetExpireTimestampVerified(v bool) { + if ta.ExpireTimestampVerified != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("ExpireTimestampVerified:%t->%t", ta.ExpireTimestampVerified, v)) + } + ta.ExpireTimestampVerified = v + ta.dirty = true } +} - tappable.updateFromProcessTappableProto(ctx, db, tappableDetails, request, timestampMs) - saveTappableRecord(ctx, db, tappable) - return fmt.Sprintf("ProcessTappableOutProto %d", id) +func (ta *Tappable) SetUpdated(v int64) { + if ta.Updated != v { + if dbDebugEnabled { + ta.changedFields = append(ta.changedFields, fmt.Sprintf("Updated:%d->%d", ta.Updated, v)) + } + ta.Updated = v + ta.dirty = true + } } diff --git a/decoder/tappable_decode.go b/decoder/tappable_decode.go new file mode 100644 index 00000000..2b6f65e9 --- /dev/null +++ b/decoder/tappable_decode.go @@ -0,0 +1,117 @@ +package decoder + +import ( + "context" + "strconv" + "time" + + "github.com/guregu/null/v6" + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func (ta *Tappable) updateFromProcessTappableProto(ctx context.Context, db db.DbDetails, tappable *pogo.ProcessTappableOutProto, request *pogo.ProcessTappableProto, timestampMs int64) { + // update from request + ta.Id = request.EncounterId // Id is primary key, don't track as dirty + location := request.GetLocation() + if spawnPointId := location.GetSpawnpointId(); spawnPointId != "" { + spawnId, err := strconv.ParseInt(spawnPointId, 16, 64) + if err != nil { + panic(err) + } + ta.SetSpawnId(null.IntFrom(spawnId)) + } + if fortId := location.GetFortId(); fortId != "" { + ta.SetFortId(null.StringFrom(fortId)) + } + ta.SetType(request.TappableTypeId) + ta.SetLat(request.LocationHintLat) + ta.SetLon(request.LocationHintLng) + ta.setExpireTimestamp(ctx, db, timestampMs) + + // update from tappable + if encounter := tappable.GetEncounter(); encounter != nil { + // tappable is a Pokèmon, encounter is sent in a separate proto + // we store this to link tappable with Pokèmon from encounter proto + ta.SetEncounter(null.IntFrom(int64(encounter.Pokemon.PokemonId))) + } else if reward := tappable.GetReward(); reward != nil { + for _, lootProto := range reward { + for _, itemProto := range lootProto.GetLootItem() { + switch t := itemProto.Type.(type) { + case *pogo.LootItemProto_Item: + ta.SetItemId(null.IntFrom(int64(t.Item))) + ta.SetCount(null.IntFrom(int64(itemProto.Count))) + case *pogo.LootItemProto_Stardust: + log.Warnf("[TAPPABLE] Reward is Stardust: %t", t.Stardust) + case *pogo.LootItemProto_Pokecoin: + log.Warnf("[TAPPABLE] Reward is Pokecoin: %t", t.Pokecoin) + case *pogo.LootItemProto_PokemonCandy: + log.Warnf("[TAPPABLE] Reward is Pokemon Candy: %v", t.PokemonCandy) + case *pogo.LootItemProto_Experience: + log.Warnf("[TAPPABLE] Reward is Experience: %t", t.Experience) + case *pogo.LootItemProto_PokemonEgg: + log.Warnf("[TAPPABLE] Reward is a Pokemon Egg: %v", t.PokemonEgg) + case *pogo.LootItemProto_AvatarTemplateId: + log.Warnf("[TAPPABLE] Reward is an Avatar Template ID: %v", t.AvatarTemplateId) + case *pogo.LootItemProto_StickerId: + log.Warnf("[TAPPABLE] Reward is a Sticker ID: %s", t.StickerId) + case *pogo.LootItemProto_MegaEnergyPokemonId: + log.Warnf("[TAPPABLE] Reward is Mega Energy Pokemon ID: %v", t.MegaEnergyPokemonId) + case *pogo.LootItemProto_XlCandy: + log.Warnf("[TAPPABLE] Reward is XL Candy: %v", t.XlCandy) + case *pogo.LootItemProto_FollowerPokemon: + log.Warnf("[TAPPABLE] Reward is a Follower Pokemon: %v", t.FollowerPokemon) + case *pogo.LootItemProto_NeutralAvatarTemplateId: + log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Template ID: %v", t.NeutralAvatarTemplateId) + case *pogo.LootItemProto_NeutralAvatarItemTemplate: + log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Item Template: %v", t.NeutralAvatarItemTemplate) + case *pogo.LootItemProto_NeutralAvatarItemDisplay: + log.Warnf("[TAPPABLE] Reward is a Neutral Avatar Item Display: %v", t.NeutralAvatarItemDisplay) + default: + log.Warnf("Unknown or unset Type") + } + } + } + } +} + +func (ta *Tappable) setExpireTimestamp(ctx context.Context, db db.DbDetails, timestampMs int64) { + ta.SetExpireTimestampVerified(false) + if spawnId := ta.SpawnId.ValueOrZero(); spawnId != 0 { + spawnPoint, unlock, _ := getSpawnpointRecord(ctx, db, spawnId) + if spawnPoint != nil && spawnPoint.DespawnSec.Valid { + despawnSecond := int(spawnPoint.DespawnSec.ValueOrZero()) + unlock() + + date := time.Unix(timestampMs/1000, 0) + secondOfHour := date.Second() + date.Minute()*60 + + despawnOffset := despawnSecond - secondOfHour + if despawnOffset < 0 { + despawnOffset += 3600 + } + ta.SetExpireTimestamp(null.IntFrom(int64(timestampMs)/1000 + int64(despawnOffset))) + ta.SetExpireTimestampVerified(true) + } else { + if unlock != nil { + unlock() + } + ta.setUnknownTimestamp(timestampMs / 1000) + } + } else if fortId := ta.FortId.ValueOrZero(); fortId != "" { + // we don't know any despawn times from lured/fort tappables + ta.SetExpireTimestamp(null.IntFrom(int64(timestampMs)/1000 + int64(120))) + } +} + +func (ta *Tappable) setUnknownTimestamp(now int64) { + if !ta.ExpireTimestamp.Valid { + ta.SetExpireTimestamp(null.IntFrom(now + 20*60)) + } else { + if ta.ExpireTimestamp.Int64 < now { + ta.SetExpireTimestamp(null.IntFrom(now + 10*60)) + } + } +} diff --git a/decoder/tappable_process.go b/decoder/tappable_process.go new file mode 100644 index 00000000..1001ac5e --- /dev/null +++ b/decoder/tappable_process.go @@ -0,0 +1,26 @@ +package decoder + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "golbat/db" + "golbat/pogo" +) + +func UpdateTappable(ctx context.Context, db db.DbDetails, request *pogo.ProcessTappableProto, tappableDetails *pogo.ProcessTappableOutProto, timestampMs int64) string { + id := request.GetEncounterId() + + tappable, unlock, err := getOrCreateTappableRecord(ctx, db, id) + if err != nil { + log.Printf("getOrCreateTappableRecord: %s", err) + return "Error getting tappable" + } + defer unlock() + + tappable.updateFromProcessTappableProto(ctx, db, tappableDetails, request, timestampMs) + saveTappableRecord(ctx, db, tappable) + return fmt.Sprintf("ProcessTappableOutProto %d", id) +} diff --git a/decoder/tappable_state.go b/decoder/tappable_state.go new file mode 100644 index 00000000..517c22ab --- /dev/null +++ b/decoder/tappable_state.go @@ -0,0 +1,157 @@ +package decoder + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "time" + + "github.com/jellydator/ttlcache/v3" + log "github.com/sirupsen/logrus" + + "golbat/db" +) + +func loadTappableFromDatabase(ctx context.Context, db db.DbDetails, id uint64, tappable *Tappable) error { + err := db.GeneralDb.GetContext(ctx, tappable, + `SELECT id, lat, lon, fort_id, spawn_id, type, pokemon_id, item_id, count, expire_timestamp, expire_timestamp_verified, updated + FROM tappable WHERE id = ?`, strconv.FormatUint(id, 10)) + statsCollector.IncDbQuery("select tappable", err) + return err +} + +// PeekTappableRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func PeekTappableRecord(id uint64) (*Tappable, func(), error) { + if item := tappableCache.Get(id); item != nil { + tappable := item.Value() + tappable.Lock() + return tappable, func() { tappable.Unlock() }, nil + } + return nil, nil, nil +} + +// getTappableRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getTappableRecordReadOnly(ctx context.Context, db db.DbDetails, id uint64) (*Tappable, func(), error) { + // Check cache first + if item := tappableCache.Get(id); item != nil { + tappable := item.Value() + tappable.Lock() + return tappable, func() { tappable.Unlock() }, nil + } + + dbTappable := Tappable{} + err := loadTappableFromDatabase(ctx, db, id, &dbTappable) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + dbTappable.ClearDirty() + + // Atomically cache the loaded Tappable - if another goroutine raced us, + // we'll get their Tappable and use that instead (ensuring same mutex) + existingTappable, _ := tappableCache.GetOrSetFunc(id, func() *Tappable { + return &dbTappable + }) + + tappable := existingTappable.Value() + tappable.Lock() + return tappable, func() { tappable.Unlock() }, nil +} + +// getOrCreateTappableRecord gets existing or creates new, locked. +// Caller MUST call returned unlock function. +func getOrCreateTappableRecord(ctx context.Context, db db.DbDetails, id uint64) (*Tappable, func(), error) { + // Create new Tappable atomically - function only called if key doesn't exist + tappableItem, _ := tappableCache.GetOrSetFunc(id, func() *Tappable { + return &Tappable{Id: id, newRecord: true} + }) + + tappable := tappableItem.Value() + tappable.Lock() + + if tappable.newRecord { + // We should attempt to load from database + err := loadTappableFromDatabase(ctx, db, id, tappable) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + tappable.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + tappable.newRecord = false + tappable.ClearDirty() + } + } + + return tappable, func() { tappable.Unlock() }, nil +} + +func saveTappableRecord(ctx context.Context, details db.DbDetails, tappable *Tappable) { + // Skip save if not dirty and not new + if !tappable.IsDirty() && !tappable.IsNewRecord() { + return + } + + now := time.Now().Unix() + tappable.SetUpdated(now) + + if tappable.IsNewRecord() { + if dbDebugEnabled { + dbDebugLog("INSERT", "Tappable", strconv.FormatUint(tappable.Id, 10), tappable.changedFields) + } + res, err := details.GeneralDb.NamedExecContext(ctx, fmt.Sprintf(` + INSERT INTO tappable ( + id, lat, lon, fort_id, spawn_id, type, pokemon_id, item_id, count, expire_timestamp, expire_timestamp_verified, updated + ) VALUES ( + "%d", :lat, :lon, :fort_id, :spawn_id, :type, :pokemon_id, :item_id, :count, :expire_timestamp, :expire_timestamp_verified, :updated + ) + `, tappable.Id), tappable) + statsCollector.IncDbQuery("insert tappable", err) + if err != nil { + log.Errorf("insert tappable %d: %s", tappable.Id, err) + return + } + _ = res + } else { + if dbDebugEnabled { + dbDebugLog("UPDATE", "Tappable", strconv.FormatUint(tappable.Id, 10), tappable.changedFields) + } + res, err := details.GeneralDb.NamedExecContext(ctx, fmt.Sprintf(` + UPDATE tappable SET + lat = :lat, + lon = :lon, + fort_id = :fort_id, + spawn_id = :spawn_id, + type = :type, + pokemon_id = :pokemon_id, + item_id = :item_id, + count = :count, + expire_timestamp = :expire_timestamp, + expire_timestamp_verified = :expire_timestamp_verified, + updated = :updated + WHERE id = "%d" + `, tappable.Id), tappable) + statsCollector.IncDbQuery("update tappable", err) + if err != nil { + log.Errorf("update tappable %d: %s", tappable.Id, err) + return + } + _ = res + } + if dbDebugEnabled { + tappable.changedFields = tappable.changedFields[:0] + } + tappable.ClearDirty() + if tappable.IsNewRecord() { + tappableCache.Set(tappable.Id, tappable, ttlcache.DefaultTTL) + tappable.newRecord = false + } +} diff --git a/decoder/weather.go b/decoder/weather.go index ae5aa573..b75a64a4 100644 --- a/decoder/weather.go +++ b/decoder/weather.go @@ -3,19 +3,24 @@ package decoder import ( "context" "database/sql" + "errors" + "sync" + "golbat/db" "golbat/pogo" "golbat/webhooks" "github.com/golang/geo/s2" + "github.com/guregu/null/v6" "github.com/jellydator/ttlcache/v3" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) // Weather struct. -// REMINDER! Keep hasChangesWeather updated after making changes +// REMINDER! Dirty flag pattern - use setter methods to modify fields type Weather struct { + mu sync.Mutex `db:"-" json:"-"` // Object-level mutex + Id int64 `db:"id"` Latitude float64 `db:"latitude"` Longitude float64 `db:"longitude"` @@ -31,6 +36,17 @@ type Weather struct { Severity null.Int `db:"severity"` WarnWeather null.Bool `db:"warn_weather"` UpdatedMs int64 `db:"updated"` + + dirty bool `db:"-" json:"-"` // Not persisted - tracks if object needs saving + newRecord bool `db:"-" json:"-"` // Not persisted - tracks if this is a new record + + oldValues WeatherOldValues `db:"-" json:"-"` // Old values for webhook comparison +} + +// WeatherOldValues holds old field values for webhook comparison +type WeatherOldValues struct { + GameplayCondition null.Int + WarnWeather null.Bool } // CREATE TABLE `weather` ( @@ -52,79 +68,282 @@ type Weather struct { // PRIMARY KEY (`id`) //) -func getWeatherRecord(ctx context.Context, db db.DbDetails, weatherId int64) (*Weather, error) { - inMemoryWeather := weatherCache.Get(weatherId) - if inMemoryWeather != nil { - weather := inMemoryWeather.Value() - return &weather, nil +// IsDirty returns true if any field has been modified +func (weather *Weather) IsDirty() bool { + return weather.dirty +} + +// ClearDirty resets the dirty flag (call after saving to DB) +func (weather *Weather) ClearDirty() { + weather.dirty = false +} + +// IsNewRecord returns true if this is a new record (not yet in DB) +func (weather *Weather) IsNewRecord() bool { + return weather.newRecord +} + +// Lock acquires the Weather's mutex +func (weather *Weather) Lock() { + weather.mu.Lock() +} + +// Unlock releases the Weather's mutex +func (weather *Weather) Unlock() { + weather.mu.Unlock() +} + +// snapshotOldValues saves current values for webhook comparison +// Call this after loading from cache/DB but before modifications +func (weather *Weather) snapshotOldValues() { + weather.oldValues = WeatherOldValues{ + GameplayCondition: weather.GameplayCondition, + WarnWeather: weather.WarnWeather, + } +} + +// --- Set methods with dirty tracking --- + +func (weather *Weather) SetId(v int64) { + if weather.Id != v { + weather.Id = v + weather.dirty = true + } +} + +func (weather *Weather) SetLatitude(v float64) { + if !floatAlmostEqual(weather.Latitude, v, floatTolerance) { + weather.Latitude = v + weather.dirty = true + } +} + +func (weather *Weather) SetLongitude(v float64) { + if !floatAlmostEqual(weather.Longitude, v, floatTolerance) { + weather.Longitude = v + weather.dirty = true + } +} + +func (weather *Weather) SetLevel(v null.Int) { + if weather.Level != v { + weather.Level = v + weather.dirty = true + } +} + +func (weather *Weather) SetGameplayCondition(v null.Int) { + if weather.GameplayCondition != v { + weather.GameplayCondition = v + weather.dirty = true + } +} + +func (weather *Weather) SetWindDirection(v null.Int) { + if weather.WindDirection != v { + weather.WindDirection = v + weather.dirty = true + } +} + +func (weather *Weather) SetCloudLevel(v null.Int) { + if weather.CloudLevel != v { + weather.CloudLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetRainLevel(v null.Int) { + if weather.RainLevel != v { + weather.RainLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetWindLevel(v null.Int) { + if weather.WindLevel != v { + weather.WindLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetSnowLevel(v null.Int) { + if weather.SnowLevel != v { + weather.SnowLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetFogLevel(v null.Int) { + if weather.FogLevel != v { + weather.FogLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetSpecialEffectLevel(v null.Int) { + if weather.SpecialEffectLevel != v { + weather.SpecialEffectLevel = v + weather.dirty = true + } +} + +func (weather *Weather) SetSeverity(v null.Int) { + if weather.Severity != v { + weather.Severity = v + weather.dirty = true } - weather := Weather{} +} - err := db.GeneralDb.GetContext(ctx, &weather, "SELECT id, latitude, longitude, level, gameplay_condition, wind_direction, cloud_level, rain_level, wind_level, snow_level, fog_level, special_effect_level, severity, warn_weather, updated FROM weather WHERE id = ?", weatherId) +func (weather *Weather) SetWarnWeather(v null.Bool) { + if weather.WarnWeather != v { + weather.WarnWeather = v + weather.dirty = true + } +} +func loadWeatherFromDatabase(ctx context.Context, db db.DbDetails, weatherId int64, weather *Weather) error { + err := db.GeneralDb.GetContext(ctx, weather, + "SELECT id, latitude, longitude, level, gameplay_condition, wind_direction, cloud_level, rain_level, wind_level, snow_level, fog_level, special_effect_level, severity, warn_weather, updated FROM weather WHERE id = ?", weatherId) statsCollector.IncDbQuery("select weather", err) - if err == sql.ErrNoRows { - return nil, nil + if err == nil { + weather.UpdatedMs *= 1000 + } + return err +} + +// peekWeatherRecord - cache-only lookup, no DB fallback, returns locked. +// Caller MUST call returned unlock function if non-nil. +func peekWeatherRecord(weatherId int64) (*Weather, func(), error) { + if item := weatherCache.Get(weatherId); item != nil { + weather := item.Value() + weather.Lock() + return weather, func() { weather.Unlock() }, nil + } + return nil, nil, nil +} + +// getWeatherRecordReadOnly acquires lock but does NOT take snapshot. +// Use for read-only checks. Will cause a backing database lookup. +// Caller MUST call returned unlock function if non-nil. +func getWeatherRecordReadOnly(ctx context.Context, db db.DbDetails, weatherId int64) (*Weather, func(), error) { + // Check cache first + if item := weatherCache.Get(weatherId); item != nil { + weather := item.Value() + weather.Lock() + return weather, func() { weather.Unlock() }, nil } + dbWeather := Weather{} + err := loadWeatherFromDatabase(ctx, db, weatherId, &dbWeather) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil + } if err != nil { - return nil, err + return nil, nil, err } + dbWeather.ClearDirty() + + // Atomically cache the loaded Weather - if another goroutine raced us, + // we'll get their Weather and use that instead (ensuring same mutex) + existingWeather, _ := weatherCache.GetOrSetFunc(weatherId, func() *Weather { + return &dbWeather + }) + + weather := existingWeather.Value() + weather.Lock() + return weather, func() { weather.Unlock() }, nil +} - weather.UpdatedMs *= 1000 - weatherCache.Set(weatherId, weather, ttlcache.DefaultTTL) - return &weather, nil +// getWeatherRecordForUpdate acquires lock AND takes snapshot for webhook comparison. +// Caller MUST call returned unlock function if non-nil. +func getWeatherRecordForUpdate(ctx context.Context, db db.DbDetails, weatherId int64) (*Weather, func(), error) { + weather, unlock, err := getWeatherRecordReadOnly(ctx, db, weatherId) + if err != nil || weather == nil { + return nil, nil, err + } + weather.snapshotOldValues() + return weather, unlock, nil +} + +// getOrCreateWeatherRecord gets existing or creates new, locked with snapshot. +// Caller MUST call returned unlock function. +func getOrCreateWeatherRecord(ctx context.Context, db db.DbDetails, weatherId int64) (*Weather, func(), error) { + // Create new Weather atomically - function only called if key doesn't exist + weatherItem, _ := weatherCache.GetOrSetFunc(weatherId, func() *Weather { + return &Weather{Id: weatherId, newRecord: true} + }) + + weather := weatherItem.Value() + weather.Lock() + + if weather.newRecord { + // We should attempt to load from database + err := loadWeatherFromDatabase(ctx, db, weatherId, weather) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + weather.Unlock() + return nil, nil, err + } + } else { + // We loaded from DB + weather.newRecord = false + weather.ClearDirty() + } + } + + weather.snapshotOldValues() + return weather, func() { weather.Unlock() }, nil } func weatherCellIdFromLatLon(lat, lon float64) int64 { return int64(s2.CellIDFromLatLng(s2.LatLngFromDegrees(lat, lon)).Parent(10)) } -func (weather *Weather) updateWeatherFromClientWeatherProto(clientWeather *pogo.ClientWeatherProto) (oldGameplayCondition null.Int) { - oldGameplayCondition = weather.GameplayCondition - weather.Id = clientWeather.S2CellId +func (weather *Weather) updateWeatherFromClientWeatherProto(clientWeather *pogo.ClientWeatherProto) { + weather.SetId(clientWeather.S2CellId) s2cell := s2.CellFromCellID(s2.CellID(clientWeather.S2CellId)) - weather.Latitude = s2cell.CapBound().RectBound().Center().Lat.Degrees() - weather.Longitude = s2cell.CapBound().RectBound().Center().Lng.Degrees() - weather.Level = null.IntFrom(int64(s2cell.Level())) - weather.GameplayCondition = null.IntFrom(int64(clientWeather.GameplayWeather.GameplayCondition)) - weather.WindDirection = null.IntFrom(int64(clientWeather.DisplayWeather.WindDirection)) - weather.CloudLevel = null.IntFrom(int64(clientWeather.DisplayWeather.CloudLevel)) - weather.RainLevel = null.IntFrom(int64(clientWeather.DisplayWeather.RainLevel)) - weather.WindLevel = null.IntFrom(int64(clientWeather.DisplayWeather.WindLevel)) - weather.SnowLevel = null.IntFrom(int64(clientWeather.DisplayWeather.SnowLevel)) - weather.FogLevel = null.IntFrom(int64(clientWeather.DisplayWeather.FogLevel)) - weather.SpecialEffectLevel = null.IntFrom(int64(clientWeather.DisplayWeather.SpecialEffectLevel)) + weather.SetLatitude(s2cell.CapBound().RectBound().Center().Lat.Degrees()) + weather.SetLongitude(s2cell.CapBound().RectBound().Center().Lng.Degrees()) + weather.SetLevel(null.IntFrom(int64(s2cell.Level()))) + weather.SetGameplayCondition(null.IntFrom(int64(clientWeather.GameplayWeather.GameplayCondition))) + weather.SetWindDirection(null.IntFrom(int64(clientWeather.DisplayWeather.WindDirection))) + weather.SetCloudLevel(null.IntFrom(int64(clientWeather.DisplayWeather.CloudLevel))) + weather.SetRainLevel(null.IntFrom(int64(clientWeather.DisplayWeather.RainLevel))) + weather.SetWindLevel(null.IntFrom(int64(clientWeather.DisplayWeather.WindLevel))) + weather.SetSnowLevel(null.IntFrom(int64(clientWeather.DisplayWeather.SnowLevel))) + weather.SetFogLevel(null.IntFrom(int64(clientWeather.DisplayWeather.FogLevel))) + weather.SetSpecialEffectLevel(null.IntFrom(int64(clientWeather.DisplayWeather.SpecialEffectLevel))) for _, alert := range clientWeather.Alerts { - weather.Severity = null.IntFrom(int64(alert.Severity)) - weather.WarnWeather = null.BoolFrom(alert.WarnWeather) - } - return -} - -// hasChangesWeather compares two Weather structs -// Float tolerance: Latitude, Longitude -func hasChangesWeather(old *Weather, new *Weather) bool { - return old.Id != new.Id || - old.Level != new.Level || - old.GameplayCondition != new.GameplayCondition || - old.WindDirection != new.WindDirection || - old.CloudLevel != new.CloudLevel || - old.RainLevel != new.RainLevel || - old.WindLevel != new.WindLevel || - old.SnowLevel != new.SnowLevel || - old.FogLevel != new.FogLevel || - old.SpecialEffectLevel != new.SpecialEffectLevel || - old.Severity != new.Severity || - old.WarnWeather != new.WarnWeather || - old.UpdatedMs != new.UpdatedMs || - !floatAlmostEqual(old.Latitude, new.Latitude, floatTolerance) || - !floatAlmostEqual(old.Longitude, new.Longitude, floatTolerance) -} - -func createWeatherWebhooks(oldWeather *Weather, weather *Weather) { - if oldWeather == nil || oldWeather.GameplayCondition.ValueOrZero() != weather.GameplayCondition.ValueOrZero() || - oldWeather.WarnWeather.ValueOrZero() != weather.WarnWeather.ValueOrZero() { + weather.SetSeverity(null.IntFrom(int64(alert.Severity))) + weather.SetWarnWeather(null.BoolFrom(alert.WarnWeather)) + } +} + +type WeatherWebhook struct { + S2CellId int64 `json:"s2_cell_id"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Polygon [4][2]float64 `json:"polygon"` + GameplayCondition int64 `json:"gameplay_condition"` + WindDirection int64 `json:"wind_direction"` + CloudLevel int64 `json:"cloud_level"` + RainLevel int64 `json:"rain_level"` + WindLevel int64 `json:"wind_level"` + SnowLevel int64 `json:"snow_level"` + FogLevel int64 `json:"fog_level"` + SpecialEffectLevel int64 `json:"special_effect_level"` + Severity int64 `json:"severity"` + WarnWeather bool `json:"warn_weather"` + Updated int64 `json:"updated"` +} + +func createWeatherWebhooks(weather *Weather) { + old := &weather.oldValues + isNew := weather.IsNewRecord() + + if isNew || old.GameplayCondition.ValueOrZero() != weather.GameplayCondition.ValueOrZero() || + old.WarnWeather.ValueOrZero() != weather.WarnWeather.ValueOrZero() { s2cell := s2.CellFromCellID(s2.CellID(weather.Id)) var polygon [4][2]float64 @@ -133,22 +352,23 @@ func createWeatherWebhooks(oldWeather *Weather, weather *Weather) { latLng := s2.LatLngFromPoint(vertex) polygon[i] = [...]float64{latLng.Lat.Degrees(), latLng.Lng.Degrees()} } - weatherHook := map[string]interface{}{ - "s2_cell_id": weather.Id, - "latitude": weather.Latitude, - "longitude": weather.Longitude, - "polygon": polygon, - "gameplay_condition": weather.GameplayCondition.ValueOrZero(), - "wind_direction": weather.WindDirection.ValueOrZero(), - "cloud_level": weather.CloudLevel.ValueOrZero(), - "rain_level": weather.RainLevel.ValueOrZero(), - "wind_level": weather.WindLevel.ValueOrZero(), - "snow_level": weather.SnowLevel.ValueOrZero(), - "fog_level": weather.FogLevel.ValueOrZero(), - "special_effect_level": weather.SpecialEffectLevel.ValueOrZero(), - "severity": weather.Severity.ValueOrZero(), - "warn_weather": weather.WarnWeather.ValueOrZero(), - "updated": weather.UpdatedMs / 1000, + + weatherHook := WeatherWebhook{ + S2CellId: weather.Id, + Latitude: weather.Latitude, + Longitude: weather.Longitude, + Polygon: polygon, + GameplayCondition: weather.GameplayCondition.ValueOrZero(), + WindDirection: weather.WindDirection.ValueOrZero(), + CloudLevel: weather.CloudLevel.ValueOrZero(), + RainLevel: weather.RainLevel.ValueOrZero(), + WindLevel: weather.WindLevel.ValueOrZero(), + SnowLevel: weather.SnowLevel.ValueOrZero(), + FogLevel: weather.FogLevel.ValueOrZero(), + SpecialEffectLevel: weather.SpecialEffectLevel.ValueOrZero(), + Severity: weather.Severity.ValueOrZero(), + WarnWeather: weather.WarnWeather.ValueOrZero(), + Updated: weather.UpdatedMs / 1000, } areas := MatchStatsGeofence(weather.Latitude, weather.Longitude) webhooksSender.AddMessage(webhooks.Weather, weatherHook, areas) @@ -156,12 +376,12 @@ func createWeatherWebhooks(oldWeather *Weather, weather *Weather) { } func saveWeatherRecord(ctx context.Context, db db.DbDetails, weather *Weather) { - oldWeather, _ := getWeatherRecord(ctx, db, weather.Id) - if oldWeather != nil && !hasChangesWeather(oldWeather, weather) { + // Skip save if not dirty and not new + if !weather.IsDirty() && !weather.IsNewRecord() { return } - if oldWeather == nil { + if weather.IsNewRecord() { res, err := db.GeneralDb.NamedExecContext(ctx, "INSERT INTO weather ("+ "id, latitude, longitude, level, gameplay_condition, wind_direction, cloud_level, rain_level, "+ @@ -202,6 +422,10 @@ func saveWeatherRecord(ctx context.Context, db db.DbDetails, weather *Weather) { } _ = res } - weatherCache.Set(weather.Id, *weather, ttlcache.DefaultTTL) - createWeatherWebhooks(oldWeather, weather) + createWeatherWebhooks(weather) + weather.ClearDirty() + if weather.IsNewRecord() { + weatherCache.Set(weather.Id, weather, ttlcache.DefaultTTL) + weather.newRecord = false + } } diff --git a/decoder/weather_iv.go b/decoder/weather_iv.go index 582e1760..2f55df6b 100644 --- a/decoder/weather_iv.go +++ b/decoder/weather_iv.go @@ -4,16 +4,17 @@ import ( "context" "encoding/json" "errors" - "golbat/db" - "golbat/pogo" "net/http" "os" "reflect" "time" + "golbat/db" + "golbat/pogo" + "github.com/golang/geo/s2" + "github.com/guregu/null/v6" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) const masterFileURL = "https://raw.githubusercontent.com/WatWowMap/Masterfile-Generator/master/master-latest-rdm.json" @@ -203,7 +204,7 @@ func ProactiveIVSwitch(ctx context.Context, db db.DbDetails, weatherUpdate Weath pokemonLocked := 0 pokemonUpdated := 0 pokemonCpUpdated := 0 - var pokemon Pokemon + //var pokemon *Pokemon pokemonTree2.Search([2]float64{cellLo.Lng.Degrees(), cellLo.Lat.Degrees()}, [2]float64{cellHi.Lng.Degrees(), cellHi.Lat.Degrees()}, func(min, max [2]float64, pokemonId uint64) bool { if !weatherCell.ContainsPoint(s2.PointFromLatLng(s2.LatLngFromDegrees(min[1], min[0]))) { return true @@ -224,28 +225,27 @@ func ProactiveIVSwitch(ctx context.Context, db db.DbDetails, weatherUpdate Weath if int8(newWeather) == pokemonLookup.PokemonLookup.Weather { return true } - pokemonMutex, _ := pokemonStripedMutex.GetLock(pokemonId) - pokemonMutex.Lock() - pokemonLocked++ - pokemonEntry := getPokemonFromCache(pokemonId) - if pokemonEntry != nil { - pokemon = pokemonEntry.Value() + + pokemon, unlock, _ := peekPokemonRecordReadOnly(pokemonId) + if pokemon != nil { + pokemonLocked++ if pokemonLookup.PokemonLookup.PokemonId == pokemon.PokemonId && (pokemon.IsDitto || int64(pokemonLookup.PokemonLookup.Form) == pokemon.Form.ValueOrZero()) && int64(newWeather) != pokemon.Weather.ValueOrZero() && pokemon.ExpireTimestamp.ValueOrZero() >= startUnix && pokemon.Updated.ValueOrZero() < timestamp { + pokemon.snapshotOldValues() pokemon.repopulateIv(int64(newWeather), pokemon.IsStrong.ValueOrZero()) if !pokemon.Cp.Valid { pokemon.Weather = null.IntFrom(int64(newWeather)) pokemon.recomputeCpIfNeeded(ctx, db, map[int64]pogo.GameplayWeatherProto_WeatherCondition{ weatherUpdate.S2CellId: pogo.GameplayWeatherProto_WeatherCondition(newWeather), }) - savePokemonRecordAsAtTime(ctx, db, &pokemon, false, toDB && pokemon.Cp.Valid, pokemon.Cp.Valid, timestamp) + savePokemonRecordAsAtTime(ctx, db, pokemon, false, toDB && pokemon.Cp.Valid, pokemon.Cp.Valid, timestamp) pokemonUpdated++ if pokemon.Cp.Valid { pokemonCpUpdated++ } } } + unlock() } - pokemonMutex.Unlock() return true }) if pokemonCpUpdated > 0 { diff --git a/go.mod b/go.mod index 66ce0646..c3b5b86d 100644 --- a/go.mod +++ b/go.mod @@ -5,57 +5,56 @@ go 1.25 toolchain go1.25.0 require ( - github.com/Depado/ginprom v1.8.1 + github.com/Depado/ginprom v1.8.2 github.com/UnownHash/gohbem v0.12.0 - github.com/getsentry/sentry-go v0.35.1 - github.com/gin-gonic/gin v1.10.1 + github.com/getsentry/sentry-go v0.42.0 + github.com/gin-gonic/gin v1.11.0 github.com/go-sql-driver/mysql v1.9.3 github.com/goccy/go-json v0.10.5 - github.com/golang-migrate/migrate/v4 v4.18.3 - github.com/golang/geo v0.0.0-20250813021530-247f39904721 - github.com/grafana/pyroscope-go v1.2.4 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/golang/geo v0.0.0-20260129164528-943061e2742c + github.com/grafana/pyroscope-go v1.2.7 + github.com/guregu/null/v6 v6.0.0 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/knadh/koanf/maps v0.1.2 github.com/knadh/koanf/parsers/toml v0.1.0 - github.com/knadh/koanf/providers/file v1.2.0 + github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/providers/structs v1.0.0 - github.com/knadh/koanf/v2 v2.2.2 - github.com/nmvalera/striped-mutex v0.1.0 - github.com/paulmach/orb v0.11.1 - github.com/prometheus/client_golang v1.23.0 + github.com/knadh/koanf/v2 v2.3.2 + github.com/paulmach/orb v0.12.0 + github.com/prometheus/client_golang v1.23.2 github.com/puzpuzpuz/xsync/v3 v3.5.1 - github.com/ringsaturn/tzf v1.0.0 - github.com/ringsaturn/tzf-rel v0.0.2025-b - github.com/sirupsen/logrus v1.9.3 + github.com/ringsaturn/tzf v1.0.3 + github.com/ringsaturn/tzf-rel v0.0.2025-c + github.com/sirupsen/logrus v1.9.4 github.com/tidwall/rtree v1.10.0 github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f - google.golang.org/grpc v1.74.2 - google.golang.org/protobuf v1.36.7 - gopkg.in/guregu/null.v4 v4.0.0 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -68,22 +67,24 @@ require ( github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.17.0 // indirect - github.com/ringsaturn/tzf-rel-lite v0.0.2025-b // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/ringsaturn/tzf-rel-lite v0.0.2025-c // indirect github.com/tidwall/geoindex v1.7.0 // indirect - github.com/tidwall/geojson v1.4.5 // indirect + github.com/tidwall/geojson v1.4.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twpayne/go-polyline v1.1.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.mongodb.org/mongo-driver v1.17.4 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver v1.17.8 // indirect + go.uber.org/mock v0.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect ) diff --git a/go.sum b/go.sum index 9f2f8889..282db98a 100644 --- a/go.sum +++ b/go.sum @@ -2,46 +2,40 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Depado/ginprom v1.8.1 h1:lrQTddbRqlHq1j6SpJDySDumJlR7FEybzdX0PS3HXPc= -github.com/Depado/ginprom v1.8.1/go.mod h1:9Z+ahPJLSeMndDfnDTfiuBn2SKVAuL2yvihApWzof9A= +github.com/Depado/ginprom v1.8.2 h1:H3sXqXlHfXpoUHciuWSbod1jzc9OyaZ4edM5oYL/nUI= +github.com/Depado/ginprom v1.8.2/go.mod h1:uq9dl4TqwBr0OpkvswJURh5fmjZcbrrMoDiDFHN8dMw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/UnownHash/gohbem v0.12.0 h1:eSyioEWJSU/81i6wf5x4XaiRZBXm6dW/KuYiHKjcELI= github.com/UnownHash/gohbem v0.12.0/go.mod h1:PUeicvRH6HyjTgkuaivjYHzDUzErf2QlsXZ24m0DaNU= -github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= -github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= +github.com/appleboy/gofight/v2 v2.2.0 h1:uqQ3wzTlF1ma+r4jRCQ4cygCjrGZyZEBMBCjT/t9zRw= +github.com/appleboy/gofight/v2 v2.2.0/go.mod h1:USTV3UbA5kHBs4I91EsPi+6PIVZAx3KLorYjvtON91A= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ= -github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= -github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= -github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= -github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= -github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= -github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= -github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= -github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -51,30 +45,20 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= -github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= -github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ= -github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= -github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= -github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= +github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -83,64 +67,40 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= -github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= -github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= -github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= -github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= -github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= -github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= -github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= -github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I= -github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= -github.com/golang/geo v0.0.0-20250328065203-0b6e08c212fb h1:eqdj1jSZjgmPdSl2lr3rAwJykSe9jHxPN1zLuasKVh0= -github.com/golang/geo v0.0.0-20250328065203-0b6e08c212fb/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= -github.com/golang/geo v0.0.0-20250813021530-247f39904721 h1:Hlto+T7Ba4CJM4SN8WiA9mw3MdMUboxWsWBaUzRuJuA= -github.com/golang/geo v0.0.0-20250813021530-247f39904721/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/geo v0.0.0-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo= +github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/pyroscope-go v1.2.0 h1:aILLKjTj8CS8f/24OPMGPewQSYlhmdQMBmol1d3KGj8= -github.com/grafana/pyroscope-go v1.2.0/go.mod h1:2GHr28Nr05bg2pElS+dDsc98f3JTUh2f6Fz1hWXrqwk= -github.com/grafana/pyroscope-go v1.2.1 h1:ewi38pE6XMnoHlZYhGxS3uH5TGKA7vDhkT1T3RVkjq0= -github.com/grafana/pyroscope-go v1.2.1/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU= -github.com/grafana/pyroscope-go v1.2.4 h1:B22GMXz+O0nWLatxLuaP7o7L9dvP0clLvIpmeEQQM0Q= -github.com/grafana/pyroscope-go v1.2.4/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU= -github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= -github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= -github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= +github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -150,34 +110,20 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= -github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= -github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w= -github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= -github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= -github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= -github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0= -github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= -github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= -github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= -github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A= -github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -191,8 +137,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/loov/hrtime v1.0.3 h1:LiWKU3B9skJwRPUf0Urs9+0+OE3TxdMuiRPOTwR0gcU= -github.com/loov/hrtime v1.0.3/go.mod h1:yDY3Pwv2izeY4sq7YcPX/dtLwzg5NU1AxWuWxKwd0p0= +github.com/loov/hrtime v1.0.4 h1:K0wPQBsd9mWer2Sx8zIfpyAlF4ckZovtkEMUR/l9wpU= +github.com/loov/hrtime v1.0.4/go.mod h1:VbIwDNS2gYTRoo0RjQFdqdDlBjJLXrkDIOgoA7Jvupk= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -217,70 +163,50 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nmvalera/striped-mutex v0.1.0 h1:+fwhVWGAsgkiOqmZGZ0HzDD3p/CEt3S85wf0YnMUpL0= -github.com/nmvalera/striped-mutex v0.1.0/go.mod h1:D22iujuGIgMJ/2xSnQDK0I3kh0t3ZOiao5VPYBbq1O0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= -github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= -github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= -github.com/ringsaturn/go-cities.json v0.6.6 h1:zXEsYeQBjgJwKdGHMrq1j8d9uVNxmnbVvi2fAQAyfNM= -github.com/ringsaturn/go-cities.json v0.6.6/go.mod h1:RWApnQPG6nU558XXbY1try5mi9u9Hd667J6vr948VBo= -github.com/ringsaturn/go-cities.json v0.6.8 h1:PsbjBrEANywxKSMMbagv4qnLSg4Rz+xWeoVKY3pkWnA= -github.com/ringsaturn/tzf v0.17.0 h1:hGHBPBzJfSLmCAKg0khA7yxx9kqPPNYkggdodpKNaKI= -github.com/ringsaturn/tzf v0.17.0/go.mod h1:4Z139lkC4Btg5tse9qdQ78gWmj7X0VwJjDxxO4ydAiU= -github.com/ringsaturn/tzf v1.0.0 h1:z0M2wKJNkyCsNFv/D1cveh6F5jhGwm8aysl0GLh2rnY= -github.com/ringsaturn/tzf v1.0.0/go.mod h1:H/Fl+lPWq+5oD72UZQzFXQnYXcWs3nnyGq6PIYEN8YY= -github.com/ringsaturn/tzf-rel v0.0.2025-a h1:OLJe11vif6JMDtECkS71hAOdIzDHV+uBVgWUW3V/dZQ= -github.com/ringsaturn/tzf-rel v0.0.2025-a/go.mod h1:R6HljjIcUQclZ6bOzWDa8llkEb/x+dmMWU60jXx2BEI= -github.com/ringsaturn/tzf-rel v0.0.2025-b h1:R/YuE6HKj/IwSbb6LilwhwsHoXEkUtnHrtz29Mnkdfw= -github.com/ringsaturn/tzf-rel v0.0.2025-b/go.mod h1:p5HyM9AThIOOr5ZCyheMFAdg236o/HMMnz/DObWdnys= -github.com/ringsaturn/tzf-rel-lite v0.0.2025-a h1:V+B0brHLxYqSyAeEWim/0BhM7Hnz587ZLGe3+8YgJ6E= -github.com/ringsaturn/tzf-rel-lite v0.0.2025-a/go.mod h1:nVMtMUFC40aMfjXzc+fj3CCXrHxD8sRUXLEVfxhFVAs= -github.com/ringsaturn/tzf-rel-lite v0.0.2025-b h1:YYuKav8cpkRtDZ9yFF0kBTO3bU/TjtcawjKI91GWCa4= -github.com/ringsaturn/tzf-rel-lite v0.0.2025-b/go.mod h1:SyVF6OU+Le0vKajtTA7PvYabdYCJsDlmplHuXeCZDrw= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/ringsaturn/go-cities.json v0.6.13 h1:p5afPcJ/tEE6uzFCOzLSHJYXgWnGdPmwZB9KBrEASxc= +github.com/ringsaturn/go-cities.json v0.6.13/go.mod h1:VtklT4Sod9i6kvXXNZV63sfjeCX9l11OQfaAvPu+p4M= +github.com/ringsaturn/tzf v1.0.3 h1:DdGcCiHpS6kg0Fo0XK+YlwNGIRXK3rs+KvFAd6b5XfQ= +github.com/ringsaturn/tzf v1.0.3/go.mod h1:8wWHQjIYklMR3uG9cIkzc/otIBhit3vtAX/D78cXNpY= +github.com/ringsaturn/tzf-rel v0.0.2025-c h1:hx2KHcZzMnO2VLg/GXKKJ6vMubmPwimcen9Gf/t1KzY= +github.com/ringsaturn/tzf-rel v0.0.2025-c/go.mod h1:p5HyM9AThIOOr5ZCyheMFAdg236o/HMMnz/DObWdnys= +github.com/ringsaturn/tzf-rel-lite v0.0.2025-c h1:CUs4l73ApN87MhlAhp1UtcRe3E5UFMQnl9d9XtJiHvg= +github.com/ringsaturn/tzf-rel-lite v0.0.2025-c/go.mod h1:SyVF6OU+Le0vKajtTA7PvYabdYCJsDlmplHuXeCZDrw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -292,21 +218,21 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/cities v0.1.0 h1:CVNkmMf7NEC9Bvokf5GoSsArHCKRMTgLuubRTHnH0mE= github.com/tidwall/cities v0.1.0/go.mod h1:lV/HDp2gCcRcHJWqgt6Di54GiDrTZwh1aG2ZUPNbqa4= github.com/tidwall/geoindex v1.4.4/go.mod h1:rvVVNEFfkJVWGUdEfU8QaoOg/9zFX0h9ofWzA60mz1I= github.com/tidwall/geoindex v1.7.0 h1:jtk41sfgwIt8MEDyC3xyKSj75iXXf6rjReJGDNPtR5o= github.com/tidwall/geoindex v1.7.0/go.mod h1:rvVVNEFfkJVWGUdEfU8QaoOg/9zFX0h9ofWzA60mz1I= -github.com/tidwall/geojson v1.4.5 h1:BFVb5Pr7WZJMqFXy1LVudt5hPEWR3g4uhjk5Ezc3GzA= -github.com/tidwall/geojson v1.4.5/go.mod h1:1cn3UWfSYCJOq53NZoQ9rirdw89+DM0vw+ZOAVvuReg= +github.com/tidwall/geojson v1.4.6 h1:HpEGer4tc5ieFn8Ts8aTG9fo+hgFJkqfql4O9cgphmg= +github.com/tidwall/geojson v1.4.6/go.mod h1:1cn3UWfSYCJOq53NZoQ9rirdw89+DM0vw+ZOAVvuReg= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= -github.com/tidwall/lotsa v1.0.3 h1:lFAp3PIsS58FPmz+LzhE1mcZ67tBBCRPv5j66g6y7sg= -github.com/tidwall/lotsa v1.0.3/go.mod h1:cPF+z88hamDNDjvE+u3suxCtRMVw24Gvze9eeWGYook= +github.com/tidwall/lotsa v1.0.4 h1:7jF9n2JVRuI42E4AqBlbAcjF6ACyI+8v46/CYQY47ZI= +github.com/tidwall/lotsa v1.0.4/go.mod h1:cPF+z88hamDNDjvE+u3suxCtRMVw24Gvze9eeWGYook= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -320,10 +246,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= @@ -331,46 +255,36 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= -go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM= -go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= -go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.mongodb.org/mongo-driver v1.17.8 h1:BDP3+U3Y8K0vTrpqDJIRaXNhb/bKyoVeg6tIJsW5EhM= +go.mongodb.org/mongo-driver v1.17.8/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= -golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= -golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -378,47 +292,30 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -427,36 +324,23 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg= -gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/main.go b/main.go index 02cae765..f838c496 100644 --- a/main.go +++ b/main.go @@ -6,11 +6,18 @@ import ( "net" "net/http" "net/http/pprof" - "strings" "sync" "time" _ "time/tzdata" + "golbat/config" + db2 "golbat/db" + "golbat/decoder" + "golbat/external" + pb "golbat/grpc" + "golbat/stats_collector" + "golbat/webhooks" + "github.com/gin-gonic/gin" "github.com/go-sql-driver/mysql" "github.com/golang-migrate/migrate/v4" @@ -20,16 +27,6 @@ import ( log "github.com/sirupsen/logrus" ginlogrus "github.com/toorop/gin-logrus" "google.golang.org/grpc" - "google.golang.org/protobuf/proto" - - "golbat/config" - db2 "golbat/db" - "golbat/decoder" - "golbat/external" - pb "golbat/grpc" - "golbat/pogo" - "golbat/stats_collector" - "golbat/webhooks" ) var db *sqlx.DB @@ -210,6 +207,7 @@ func main() { _ = decoder.WatchMasterFileData() } decoder.LoadStatsGeofences() + decoder.InitPokemonPendingQueue(ctx, dbDetails, 30*time.Second, 5*time.Second) InitDeviceCache() wg.Add(1) @@ -386,727 +384,3 @@ func main() { log.Info("Golbat exiting!") } - -func decode(ctx context.Context, method int, protoData *ProtoData) { - getMethodName := func(method int, trimString bool) string { - if val, ok := pogo.Method_name[int32(method)]; ok { - if trimString && strings.HasPrefix(val, "METHOD_") { - return strings.TrimPrefix(val, "METHOD_") - } - return val - } - return fmt.Sprintf("#%d", method) - } - - if method != int(pogo.InternalPlatformClientAction_INTERNAL_PROXY_SOCIAL_ACTION) && protoData.Level < 30 { - statsCollector.IncDecodeMethods("error", "low_level", getMethodName(method, true)) - log.Debugf("Insufficient Level %d Did not process hook type %s", protoData.Level, pogo.Method(method)) - return - } - - processed := false - ignore := false - start := time.Now() - result := "" - - switch pogo.Method(method) { - case pogo.Method_METHOD_START_INCIDENT: - result = decodeStartIncident(ctx, protoData.Data) - processed = true - case pogo.Method_METHOD_INVASION_OPEN_COMBAT_SESSION: - if protoData.Request != nil { - result = decodeOpenInvasion(ctx, protoData.Request, protoData.Data) - processed = true - } - case pogo.Method_METHOD_FORT_DETAILS: - result = decodeFortDetails(ctx, protoData.Data) - processed = true - case pogo.Method_METHOD_GET_MAP_OBJECTS: - result = decodeGMO(ctx, protoData, getScanParameters(protoData)) - processed = true - case pogo.Method_METHOD_GYM_GET_INFO: - result = decodeGetGymInfo(ctx, protoData.Data) - processed = true - case pogo.Method_METHOD_ENCOUNTER: - if getScanParameters(protoData).ProcessPokemon { - result = decodeEncounter(ctx, protoData.Data, protoData.Account, protoData.TimestampMs) - } - processed = true - case pogo.Method_METHOD_DISK_ENCOUNTER: - result = decodeDiskEncounter(ctx, protoData.Data, protoData.Account) - processed = true - case pogo.Method_METHOD_FORT_SEARCH: - result = decodeQuest(ctx, protoData.Data, protoData.HaveAr) - processed = true - case pogo.Method_METHOD_GET_PLAYER: - ignore = true - case pogo.Method_METHOD_GET_HOLOHOLO_INVENTORY: - ignore = true - case pogo.Method_METHOD_CREATE_COMBAT_CHALLENGE: - ignore = true - case pogo.Method(pogo.InternalPlatformClientAction_INTERNAL_PROXY_SOCIAL_ACTION): - if protoData.Request != nil { - result = decodeSocialActionWithRequest(protoData.Request, protoData.Data) - processed = true - } - case pogo.Method_METHOD_GET_MAP_FORTS: - result = decodeGetMapForts(ctx, protoData.Data) - processed = true - case pogo.Method_METHOD_GET_ROUTES: - result = decodeGetRoutes(protoData.Data) - processed = true - case pogo.Method_METHOD_GET_CONTEST_DATA: - if getScanParameters(protoData).ProcessPokestops { - // Request helps, but can be decoded without it - result = decodeGetContestData(ctx, protoData.Request, protoData.Data) - } - processed = true - case pogo.Method_METHOD_GET_POKEMON_SIZE_CONTEST_ENTRY: - // Request is essential to decode this - if protoData.Request != nil { - if getScanParameters(protoData).ProcessPokestops { - result = decodeGetPokemonSizeContestEntry(ctx, protoData.Request, protoData.Data) - } - processed = true - } - case pogo.Method_METHOD_GET_STATION_DETAILS: - if getScanParameters(protoData).ProcessStations { - // Request is essential to decode this - result = decodeGetStationDetails(ctx, protoData.Request, protoData.Data) - } - processed = true - case pogo.Method_METHOD_PROCESS_TAPPABLE: - if getScanParameters(protoData).ProcessTappables { - // Request is essential to decode this - result = decodeTappable(ctx, protoData.Request, protoData.Data, protoData.Account, protoData.TimestampMs) - } - processed = true - case pogo.Method_METHOD_GET_EVENT_RSVPS: - if getScanParameters(protoData).ProcessGyms { - result = decodeGetEventRsvp(ctx, protoData.Request, protoData.Data) - } - processed = true - case pogo.Method_METHOD_GET_EVENT_RSVP_COUNT: - if getScanParameters(protoData).ProcessGyms { - result = decodeGetEventRsvpCount(ctx, protoData.Data) - } - processed = true - default: - log.Debugf("Did not know hook type %s", pogo.Method(method)) - } - if !ignore { - elapsed := time.Since(start) - if processed == true { - statsCollector.IncDecodeMethods("ok", "", getMethodName(method, true)) - log.Debugf("%s/%s %s - %s - %s", protoData.Uuid, protoData.Account, pogo.Method(method), elapsed, result) - } else { - log.Debugf("%s/%s %s - %s - %s", protoData.Uuid, protoData.Account, pogo.Method(method), elapsed, "**Did not process**") - statsCollector.IncDecodeMethods("unprocessed", "", getMethodName(method, true)) - } - } -} - -func getScanParameters(protoData *ProtoData) decoder.ScanParameters { - return decoder.FindScanConfiguration(protoData.ScanContext, protoData.Lat, protoData.Lon) -} - -func decodeQuest(ctx context.Context, sDec []byte, haveAr *bool) string { - if haveAr == nil { - statsCollector.IncDecodeQuest("error", "missing_ar_info") - log.Infoln("Cannot determine AR quest - ignoring") - // We should either assume AR quest, or trace inventory like RDM probably - return "No AR quest info" - } - decodedQuest := &pogo.FortSearchOutProto{} - if err := proto.Unmarshal(sDec, decodedQuest); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeQuest("error", "parse") - return "Parse failure" - } - - if decodedQuest.Result != pogo.FortSearchOutProto_SUCCESS { - statsCollector.IncDecodeQuest("error", "non_success") - res := fmt.Sprintf(`GymGetInfoOutProto: Ignored non-success value %d:%s`, decodedQuest.Result, - pogo.FortSearchOutProto_Result_name[int32(decodedQuest.Result)]) - return res - } - - return decoder.UpdatePokestopWithQuest(ctx, dbDetails, decodedQuest, *haveAr) - -} - -func decodeSocialActionWithRequest(request []byte, payload []byte) string { - var proxyRequestProto pogo.ProxyRequestProto - - if err := proto.Unmarshal(request, &proxyRequestProto); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeSocialActionWithRequest("error", "request_parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - var proxyResponseProto pogo.ProxyResponseProto - - if err := proto.Unmarshal(payload, &proxyResponseProto); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeSocialActionWithRequest("error", "response_parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if proxyResponseProto.Status != pogo.ProxyResponseProto_COMPLETED && proxyResponseProto.Status != pogo.ProxyResponseProto_COMPLETED_AND_REASSIGNED { - statsCollector.IncDecodeSocialActionWithRequest("error", "non_success") - return fmt.Sprintf("unsuccessful proxyResponseProto response %d %s", int(proxyResponseProto.Status), proxyResponseProto.Status) - } - - switch pogo.InternalSocialAction(proxyRequestProto.GetAction()) { - case pogo.InternalSocialAction_SOCIAL_ACTION_LIST_FRIEND_STATUS: - statsCollector.IncDecodeSocialActionWithRequest("ok", "list_friend_status") - return decodeGetFriendDetails(proxyResponseProto.Payload) - case pogo.InternalSocialAction_SOCIAL_ACTION_SEARCH_PLAYER: - statsCollector.IncDecodeSocialActionWithRequest("ok", "search_player") - return decodeSearchPlayer(&proxyRequestProto, proxyResponseProto.Payload) - - } - - statsCollector.IncDecodeSocialActionWithRequest("ok", "unknown") - return fmt.Sprintf("Did not process %s", pogo.InternalSocialAction(proxyRequestProto.GetAction()).String()) -} - -func decodeGetFriendDetails(payload []byte) string { - var getFriendDetailsOutProto pogo.InternalGetFriendDetailsOutProto - getFriendDetailsError := proto.Unmarshal(payload, &getFriendDetailsOutProto) - - if getFriendDetailsError != nil { - statsCollector.IncDecodeGetFriendDetails("error", "parse") - log.Errorf("Failed to parse %s", getFriendDetailsError) - return fmt.Sprintf("Failed to parse %s", getFriendDetailsError) - } - - if getFriendDetailsOutProto.GetResult() != pogo.InternalGetFriendDetailsOutProto_SUCCESS || getFriendDetailsOutProto.GetFriend() == nil { - statsCollector.IncDecodeGetFriendDetails("error", "non_success") - return fmt.Sprintf("unsuccessful get friends details") - } - - failures := 0 - - for _, friend := range getFriendDetailsOutProto.GetFriend() { - player := friend.GetPlayer() - - updatePlayerError := decoder.UpdatePlayerRecordWithPlayerSummary(dbDetails, player, player.PublicData, "", player.GetPlayerId()) - if updatePlayerError != nil { - failures++ - } - } - - statsCollector.IncDecodeGetFriendDetails("ok", "") - return fmt.Sprintf("%d players decoded on %d", len(getFriendDetailsOutProto.GetFriend())-failures, len(getFriendDetailsOutProto.GetFriend())) -} - -func decodeSearchPlayer(proxyRequestProto *pogo.ProxyRequestProto, payload []byte) string { - var searchPlayerOutProto pogo.InternalSearchPlayerOutProto - searchPlayerOutError := proto.Unmarshal(payload, &searchPlayerOutProto) - - if searchPlayerOutError != nil { - log.Errorf("Failed to parse %s", searchPlayerOutError) - statsCollector.IncDecodeSearchPlayer("error", "parse") - return fmt.Sprintf("Failed to parse %s", searchPlayerOutError) - } - - if searchPlayerOutProto.GetResult() != pogo.InternalSearchPlayerOutProto_SUCCESS || searchPlayerOutProto.GetPlayer() == nil { - statsCollector.IncDecodeSearchPlayer("error", "non_success") - return fmt.Sprintf("unsuccessful search player response") - } - - var searchPlayerProto pogo.InternalSearchPlayerProto - searchPlayerError := proto.Unmarshal(proxyRequestProto.GetPayload(), &searchPlayerProto) - - if searchPlayerError != nil || searchPlayerProto.GetFriendCode() == "" { - statsCollector.IncDecodeSearchPlayer("error", "parse") - return fmt.Sprintf("Failed to parse %s", searchPlayerError) - } - - player := searchPlayerOutProto.GetPlayer() - updatePlayerError := decoder.UpdatePlayerRecordWithPlayerSummary(dbDetails, player, player.PublicData, searchPlayerProto.GetFriendCode(), "") - if updatePlayerError != nil { - statsCollector.IncDecodeSearchPlayer("error", "update") - return fmt.Sprintf("Failed update player %s", updatePlayerError) - } - - statsCollector.IncDecodeSearchPlayer("ok", "") - return fmt.Sprintf("1 player decoded from SearchPlayerProto") -} - -func decodeFortDetails(ctx context.Context, sDec []byte) string { - decodedFort := &pogo.FortDetailsOutProto{} - if err := proto.Unmarshal(sDec, decodedFort); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeFortDetails("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - switch decodedFort.FortType { - case pogo.FortType_CHECKPOINT: - statsCollector.IncDecodeFortDetails("ok", "pokestop") - return decoder.UpdatePokestopRecordWithFortDetailsOutProto(ctx, dbDetails, decodedFort) - case pogo.FortType_GYM: - statsCollector.IncDecodeFortDetails("ok", "gym") - return decoder.UpdateGymRecordWithFortDetailsOutProto(ctx, dbDetails, decodedFort) - } - - statsCollector.IncDecodeFortDetails("ok", "unknown") - return "Unknown fort type" -} - -func decodeGetMapForts(ctx context.Context, sDec []byte) string { - decodedMapForts := &pogo.GetMapFortsOutProto{} - if err := proto.Unmarshal(sDec, decodedMapForts); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeGetMapForts("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedMapForts.Status != pogo.GetMapFortsOutProto_SUCCESS { - statsCollector.IncDecodeGetMapForts("error", "non_success") - res := fmt.Sprintf(`GetMapFortsOutProto: Ignored non-success value %d:%s`, decodedMapForts.Status, - pogo.GetMapFortsOutProto_Status_name[int32(decodedMapForts.Status)]) - return res - } - - statsCollector.IncDecodeGetMapForts("ok", "") - var outputString string - processedForts := 0 - - for _, fort := range decodedMapForts.Fort { - status, output := decoder.UpdateFortRecordWithGetMapFortsOutProto(ctx, dbDetails, fort) - if status { - processedForts += 1 - outputString += output + ", " - } - } - - if processedForts > 0 { - return fmt.Sprintf("Updated %d forts: %s", processedForts, outputString) - } - return "No forts updated" -} - -func decodeGetRoutes(payload []byte) string { - getRoutesOutProto := &pogo.GetRoutesOutProto{} - if err := proto.Unmarshal(payload, getRoutesOutProto); err != nil { - return fmt.Sprintf("failed to decode GetRoutesOutProto %s", err) - } - - if getRoutesOutProto.Status != pogo.GetRoutesOutProto_SUCCESS { - return fmt.Sprintf("GetRoutesOutProto: Ignored non-success value %d:%s", getRoutesOutProto.Status, getRoutesOutProto.Status.String()) - } - - decodeSuccesses := map[string]bool{} - decodeErrors := map[string]bool{} - - for _, routeMapCell := range getRoutesOutProto.GetRouteMapCell() { - for _, route := range routeMapCell.GetRoute() { - //TODO we need to check the repeated field, for now access last element - routeSubmissionStatus := route.RouteSubmissionStatus[len(route.RouteSubmissionStatus)-1] - if routeSubmissionStatus != nil && routeSubmissionStatus.Status != pogo.RouteSubmissionStatus_PUBLISHED { - log.Warnf("Non published Route found in GetRoutesOutProto, status: %s", routeSubmissionStatus.String()) - continue - } - decodeError := decoder.UpdateRouteRecordWithSharedRouteProto(dbDetails, route) - if decodeError != nil { - if decodeErrors[route.Id] != true { - decodeErrors[route.Id] = true - } - log.Errorf("Failed to decode route %s", decodeError) - } else if decodeSuccesses[route.Id] != true { - decodeSuccesses[route.Id] = true - } - } - } - - return fmt.Sprintf( - "Decoded %d routes, failed to decode %d routes, from %d cells", - len(decodeSuccesses), - len(decodeErrors), - len(getRoutesOutProto.GetRouteMapCell()), - ) -} - -func decodeGetGymInfo(ctx context.Context, sDec []byte) string { - decodedGymInfo := &pogo.GymGetInfoOutProto{} - if err := proto.Unmarshal(sDec, decodedGymInfo); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeGetGymInfo("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedGymInfo.Result != pogo.GymGetInfoOutProto_SUCCESS { - statsCollector.IncDecodeGetGymInfo("error", "non_success") - res := fmt.Sprintf(`GymGetInfoOutProto: Ignored non-success value %d:%s`, decodedGymInfo.Result, - pogo.GymGetInfoOutProto_Result_name[int32(decodedGymInfo.Result)]) - return res - } - - statsCollector.IncDecodeGetGymInfo("ok", "") - return decoder.UpdateGymRecordWithGymInfoProto(ctx, dbDetails, decodedGymInfo) -} - -func decodeEncounter(ctx context.Context, sDec []byte, username string, timestampMs int64) string { - decodedEncounterInfo := &pogo.EncounterOutProto{} - if err := proto.Unmarshal(sDec, decodedEncounterInfo); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeEncounter("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedEncounterInfo.Status != pogo.EncounterOutProto_ENCOUNTER_SUCCESS { - statsCollector.IncDecodeEncounter("error", "non_success") - res := fmt.Sprintf(`EncounterOutProto: Ignored non-success value %d:%s`, decodedEncounterInfo.Status, - pogo.EncounterOutProto_Status_name[int32(decodedEncounterInfo.Status)]) - return res - } - - statsCollector.IncDecodeEncounter("ok", "") - return decoder.UpdatePokemonRecordWithEncounterProto(ctx, dbDetails, decodedEncounterInfo, username, timestampMs) -} - -func decodeDiskEncounter(ctx context.Context, sDec []byte, username string) string { - decodedEncounterInfo := &pogo.DiskEncounterOutProto{} - if err := proto.Unmarshal(sDec, decodedEncounterInfo); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeDiskEncounter("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedEncounterInfo.Result != pogo.DiskEncounterOutProto_SUCCESS { - statsCollector.IncDecodeDiskEncounter("error", "non_success") - res := fmt.Sprintf(`DiskEncounterOutProto: Ignored non-success value %d:%s`, decodedEncounterInfo.Result, - pogo.DiskEncounterOutProto_Result_name[int32(decodedEncounterInfo.Result)]) - return res - } - - statsCollector.IncDecodeDiskEncounter("ok", "") - return decoder.UpdatePokemonRecordWithDiskEncounterProto(ctx, dbDetails, decodedEncounterInfo, username) -} - -func decodeStartIncident(ctx context.Context, sDec []byte) string { - decodedIncident := &pogo.StartIncidentOutProto{} - if err := proto.Unmarshal(sDec, decodedIncident); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeStartIncident("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedIncident.Status != pogo.StartIncidentOutProto_SUCCESS { - statsCollector.IncDecodeStartIncident("error", "non_success") - res := fmt.Sprintf(`GiovanniOutProto: Ignored non-success value %d:%s`, decodedIncident.Status, - pogo.StartIncidentOutProto_Status_name[int32(decodedIncident.Status)]) - return res - } - - statsCollector.IncDecodeStartIncident("ok", "") - return decoder.ConfirmIncident(ctx, dbDetails, decodedIncident) -} - -func decodeOpenInvasion(ctx context.Context, request []byte, payload []byte) string { - decodeOpenInvasionRequest := &pogo.OpenInvasionCombatSessionProto{} - - if err := proto.Unmarshal(request, decodeOpenInvasionRequest); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeOpenInvasion("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - if decodeOpenInvasionRequest.IncidentLookup == nil { - return "Invalid OpenInvasionCombatSessionProto received" - } - - decodedOpenInvasionResponse := &pogo.OpenInvasionCombatSessionOutProto{} - if err := proto.Unmarshal(payload, decodedOpenInvasionResponse); err != nil { - log.Errorf("Failed to parse %s", err) - statsCollector.IncDecodeOpenInvasion("error", "parse") - return fmt.Sprintf("Failed to parse %s", err) - } - - if decodedOpenInvasionResponse.Status != pogo.InvasionStatus_SUCCESS { - statsCollector.IncDecodeOpenInvasion("error", "non_success") - res := fmt.Sprintf(`InvasionLineupOutProto: Ignored non-success value %d:%s`, decodedOpenInvasionResponse.Status, - pogo.InvasionStatus_Status_name[int32(decodedOpenInvasionResponse.Status)]) - return res - } - - statsCollector.IncDecodeOpenInvasion("ok", "") - return decoder.UpdateIncidentLineup(ctx, dbDetails, decodeOpenInvasionRequest, decodedOpenInvasionResponse) -} - -func decodeGMO(ctx context.Context, protoData *ProtoData, scanParameters decoder.ScanParameters) string { - decodedGmo := &pogo.GetMapObjectsOutProto{} - - if err := proto.Unmarshal(protoData.Data, decodedGmo); err != nil { - statsCollector.IncDecodeGMO("error", "parse") - log.Errorf("Failed to parse %s", err) - } - - if decodedGmo.Status != pogo.GetMapObjectsOutProto_SUCCESS { - statsCollector.IncDecodeGMO("error", "non_success") - res := fmt.Sprintf(`GetMapObjectsOutProto: Ignored non-success value %d:%s`, decodedGmo.Status, - pogo.GetMapObjectsOutProto_Status_name[int32(decodedGmo.Status)]) - return res - } - - var newForts []decoder.RawFortData - var newStations []decoder.RawStationData - var newWildPokemon []decoder.RawWildPokemonData - var newNearbyPokemon []decoder.RawNearbyPokemonData - var newMapPokemon []decoder.RawMapPokemonData - var newMapCells []uint64 - var cellsToBeCleaned []uint64 - - // track forts per cell for memory-based cleanup (only if tracker enabled) - cellForts := make(map[uint64]*decoder.FortTrackerGMOContents) - - if len(decodedGmo.MapCell) == 0 { - return "Skipping GetMapObjectsOutProto: No map cells found" - } - for _, mapCell := range decodedGmo.MapCell { - // initialize cell forts tracking for every map cell (so empty fort lists are seen as "no forts") - cellForts[mapCell.S2CellId] = &decoder.FortTrackerGMOContents{ - Pokestops: make([]string, 0), - Gyms: make([]string, 0), - Timestamp: mapCell.AsOfTimeMs, - } - // always mark this mapCell to be checked for removed forts. Previously only cells with forts were - // added which meant an empty fort list (all forts removed) was never passed to the tracker. - cellsToBeCleaned = append(cellsToBeCleaned, mapCell.S2CellId) - - if isCellNotEmpty(mapCell) { - newMapCells = append(newMapCells, mapCell.S2CellId) - } - - for _, fort := range mapCell.Fort { - newForts = append(newForts, decoder.RawFortData{Cell: mapCell.S2CellId, Data: fort, Timestamp: mapCell.AsOfTimeMs}) - - // track fort by type for memory-based cleanup (only if tracker enabled) - if cf, ok := cellForts[mapCell.S2CellId]; ok { - switch fort.FortType { - case pogo.FortType_GYM: - cf.Gyms = append(cf.Gyms, fort.FortId) - case pogo.FortType_CHECKPOINT: - cf.Pokestops = append(cf.Pokestops, fort.FortId) - } - } - - if fort.ActivePokemon != nil { - newMapPokemon = append(newMapPokemon, decoder.RawMapPokemonData{Cell: mapCell.S2CellId, Data: fort.ActivePokemon, Timestamp: mapCell.AsOfTimeMs}) - } - } - for _, mon := range mapCell.WildPokemon { - newWildPokemon = append(newWildPokemon, decoder.RawWildPokemonData{Cell: mapCell.S2CellId, Data: mon, Timestamp: mapCell.AsOfTimeMs}) - } - for _, mon := range mapCell.NearbyPokemon { - newNearbyPokemon = append(newNearbyPokemon, decoder.RawNearbyPokemonData{Cell: mapCell.S2CellId, Data: mon, Timestamp: mapCell.AsOfTimeMs}) - } - for _, station := range mapCell.Stations { - newStations = append(newStations, decoder.RawStationData{Cell: mapCell.S2CellId, Data: station}) - } - } - - if scanParameters.ProcessGyms || scanParameters.ProcessPokestops { - decoder.UpdateFortBatch(ctx, dbDetails, scanParameters, newForts) - } - var weatherUpdates []decoder.WeatherUpdate - if scanParameters.ProcessWeather { - weatherUpdates = decoder.UpdateClientWeatherBatch(ctx, dbDetails, decodedGmo.ClientWeather, decodedGmo.MapCell[0].AsOfTimeMs, protoData.Account) - } - if scanParameters.ProcessPokemon { - decoder.UpdatePokemonBatch(ctx, dbDetails, scanParameters, newWildPokemon, newNearbyPokemon, newMapPokemon, decodedGmo.ClientWeather, protoData.Account) - if scanParameters.ProcessWeather && scanParameters.ProactiveIVSwitching { - for _, weatherUpdate := range weatherUpdates { - go func(weatherUpdate decoder.WeatherUpdate) { - decoder.ProactiveIVSwitchSem <- true - defer func() { <-decoder.ProactiveIVSwitchSem }() - decoder.ProactiveIVSwitch(ctx, dbDetails, weatherUpdate, scanParameters.ProactiveIVSwitchingToDB, decodedGmo.MapCell[0].AsOfTimeMs/1000) - }(weatherUpdate) - } - } - } - if scanParameters.ProcessStations { - decoder.UpdateStationBatch(ctx, dbDetails, scanParameters, newStations) - } - - if scanParameters.ProcessCells { - decoder.UpdateClientMapS2CellBatch(ctx, dbDetails, newMapCells) - } - - if scanParameters.ProcessGyms || scanParameters.ProcessPokestops { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - decoder.CheckRemovedForts(ctx, dbDetails, cellsToBeCleaned, cellForts) - }() - } - - newFortsLen := len(newForts) - newStationsLen := len(newStations) - newWildPokemonLen := len(newWildPokemon) - newNearbyPokemonLen := len(newNearbyPokemon) - newMapPokemonLen := len(newMapPokemon) - newClientWeatherLen := len(decodedGmo.ClientWeather) - newMapCellsLen := len(newMapCells) - - statsCollector.IncDecodeGMO("ok", "") - statsCollector.AddDecodeGMOType("fort", float64(newFortsLen)) - statsCollector.AddDecodeGMOType("station", float64(newStationsLen)) - statsCollector.AddDecodeGMOType("wild_pokemon", float64(newWildPokemonLen)) - statsCollector.AddDecodeGMOType("nearby_pokemon", float64(newNearbyPokemonLen)) - statsCollector.AddDecodeGMOType("map_pokemon", float64(newMapPokemonLen)) - statsCollector.AddDecodeGMOType("weather", float64(newClientWeatherLen)) - statsCollector.AddDecodeGMOType("cell", float64(newMapCellsLen)) - - return fmt.Sprintf("%d cells containing %d forts %d stations %d mon %d nearby", newMapCellsLen, newFortsLen, newStationsLen, newWildPokemonLen, newNearbyPokemonLen) -} - -func isCellNotEmpty(mapCell *pogo.ClientMapCellProto) bool { - return len(mapCell.Stations) > 0 || len(mapCell.Fort) > 0 || len(mapCell.WildPokemon) > 0 || len(mapCell.NearbyPokemon) > 0 || len(mapCell.CatchablePokemon) > 0 -} - -func cellContainsForts(mapCell *pogo.ClientMapCellProto) bool { - return len(mapCell.Fort) > 0 -} - -func decodeGetContestData(ctx context.Context, request []byte, data []byte) string { - var decodedContestData pogo.GetContestDataOutProto - if err := proto.Unmarshal(data, &decodedContestData); err != nil { - log.Errorf("Failed to parse GetContestDataOutProto %s", err) - return fmt.Sprintf("Failed to parse GetContestDataOutProto %s", err) - } - - var decodedContestDataRequest pogo.GetContestDataProto - if request != nil { - if err := proto.Unmarshal(request, &decodedContestDataRequest); err != nil { - log.Errorf("Failed to parse GetContestDataProto %s", err) - return fmt.Sprintf("Failed to parse GetContestDataProto %s", err) - } - } - return decoder.UpdatePokestopWithContestData(ctx, dbDetails, &decodedContestDataRequest, &decodedContestData) -} - -func decodeGetPokemonSizeContestEntry(ctx context.Context, request []byte, data []byte) string { - var decodedPokemonSizeContestEntry pogo.GetPokemonSizeLeaderboardEntryOutProto - if err := proto.Unmarshal(data, &decodedPokemonSizeContestEntry); err != nil { - log.Errorf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) - return fmt.Sprintf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) - } - - if decodedPokemonSizeContestEntry.Status != pogo.GetPokemonSizeLeaderboardEntryOutProto_SUCCESS { - return fmt.Sprintf("Ignored GetPokemonSizeLeaderboardEntryOutProto non-success status %s", decodedPokemonSizeContestEntry.Status) - } - - var decodedPokemonSizeContestEntryRequest pogo.GetPokemonSizeLeaderboardEntryProto - if request != nil { - if err := proto.Unmarshal(request, &decodedPokemonSizeContestEntryRequest); err != nil { - log.Errorf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) - return fmt.Sprintf("Failed to parse GetPokemonSizeLeaderboardEntryOutProto %s", err) - } - } - - return decoder.UpdatePokestopWithPokemonSizeContestEntry(ctx, dbDetails, &decodedPokemonSizeContestEntryRequest, &decodedPokemonSizeContestEntry) -} - -func decodeGetStationDetails(ctx context.Context, request []byte, data []byte) string { - var decodedGetStationDetails pogo.GetStationedPokemonDetailsOutProto - if err := proto.Unmarshal(data, &decodedGetStationDetails); err != nil { - log.Errorf("Failed to parse GetStationedPokemonDetailsOutProto %s", err) - return fmt.Sprintf("Failed to parse GetStationedPokemonDetailsOutProto %s", err) - } - - var decodedGetStationDetailsRequest pogo.GetStationedPokemonDetailsProto - if request != nil { - if err := proto.Unmarshal(request, &decodedGetStationDetailsRequest); err != nil { - log.Errorf("Failed to parse GetStationedPokemonDetailsProto %s", err) - return fmt.Sprintf("Failed to parse GetStationedPokemonDetailsProto %s", err) - } - } - - if decodedGetStationDetails.Result == pogo.GetStationedPokemonDetailsOutProto_STATION_NOT_FOUND { - // station without stationed pokemon found, therefore we need to reset the columns - return decoder.ResetStationedPokemonWithStationDetailsNotFound(ctx, dbDetails, &decodedGetStationDetailsRequest) - } else if decodedGetStationDetails.Result != pogo.GetStationedPokemonDetailsOutProto_SUCCESS { - return fmt.Sprintf("Ignored GetStationedPokemonDetailsOutProto non-success status %s", decodedGetStationDetails.Result) - } - - return decoder.UpdateStationWithStationDetails(ctx, dbDetails, &decodedGetStationDetailsRequest, &decodedGetStationDetails) -} - -func decodeTappable(ctx context.Context, request, data []byte, username string, timestampMs int64) string { - var tappable pogo.ProcessTappableOutProto - if err := proto.Unmarshal(data, &tappable); err != nil { - log.Errorf("Failed to parse %s", err) - return fmt.Sprintf("Failed to parse ProcessTappableOutProto %s", err) - } - - var tappableRequest pogo.ProcessTappableProto - if request != nil { - if err := proto.Unmarshal(request, &tappableRequest); err != nil { - log.Errorf("Failed to parse %s", err) - return fmt.Sprintf("Failed to parse ProcessTappableProto %s", err) - } - } - - if tappable.Status != pogo.ProcessTappableOutProto_SUCCESS { - return fmt.Sprintf("Ignored ProcessTappableOutProto non-success status %s", tappable.Status) - } - var result string - if encounter := tappable.GetEncounter(); encounter != nil { - result = decoder.UpdatePokemonRecordWithTappableEncounter(ctx, dbDetails, &tappableRequest, encounter, username, timestampMs) - } - return result + " " + decoder.UpdateTappable(ctx, dbDetails, &tappableRequest, &tappable, timestampMs) -} - -func decodeGetEventRsvp(ctx context.Context, request []byte, data []byte) string { - var rsvp pogo.GetEventRsvpsOutProto - if err := proto.Unmarshal(data, &rsvp); err != nil { - log.Errorf("Failed to parse %s", err) - return fmt.Sprintf("Failed to parse GetEventRsvpsOutProto %s", err) - } - - var rsvpRequest pogo.GetEventRsvpsProto - if request != nil { - if err := proto.Unmarshal(request, &rsvpRequest); err != nil { - log.Errorf("Failed to parse %s", err) - return fmt.Sprintf("Failed to parse GetEventRsvpsProto %s", err) - } - } - - if rsvp.Status != pogo.GetEventRsvpsOutProto_SUCCESS { - return fmt.Sprintf("Ignored GetEventRsvpsOutProto non-success status %s", rsvp.Status) - } - - switch op := rsvpRequest.EventDetails.(type) { - case *pogo.GetEventRsvpsProto_Raid: - return decoder.UpdateGymRecordWithRsvpProto(ctx, dbDetails, op.Raid, &rsvp) - case *pogo.GetEventRsvpsProto_GmaxBattle: - return "Unsupported GmaxBattle Rsvp received" - } - - return "Failed to parse GetEventRsvpsProto - unknown event type" -} - -func decodeGetEventRsvpCount(ctx context.Context, data []byte) string { - var rsvp pogo.GetEventRsvpCountOutProto - if err := proto.Unmarshal(data, &rsvp); err != nil { - log.Errorf("Failed to parse %s", err) - return fmt.Sprintf("Failed to parse GetEventRsvpCountOutProto %s", err) - } - - if rsvp.Status != pogo.GetEventRsvpCountOutProto_SUCCESS { - return fmt.Sprintf("Ignored GetEventRsvpCountOutProto non-success status %s", rsvp.Status) - } - - var clearLocations []string - for _, rsvpDetails := range rsvp.RsvpDetails { - if rsvpDetails.MaybeCount == 0 && rsvpDetails.GoingCount == 0 { - clearLocations = append(clearLocations, rsvpDetails.LocationId) - decoder.ClearGymRsvp(ctx, dbDetails, rsvpDetails.LocationId) - } - } - - return "Cleared RSVP @ " + strings.Join(clearLocations, ", ") -} diff --git a/routes.go b/routes.go index e68c16a6..0cf7efff 100644 --- a/routes.go +++ b/routes.go @@ -40,6 +40,10 @@ type InboundRawData struct { HaveAr *bool } +type StatusResponse struct { + Status string `json:"status"` +} + func questsHeldHasARTask(quests_held any) *bool { const ar_quest_id = int64(pogo.QuestType_QUEST_GEOTARGETED_AR_SCAN) @@ -346,17 +350,13 @@ func ClearQuests(c *gin.Context) { decoder.ClearQuestsWithinGeofence(ctx, dbDetails, fence) log.Infof("Clear quest took %s", time.Since(startTime)) - c.JSON(http.StatusAccepted, map[string]interface{}{ - "status": "ok", - }) + c.JSON(http.StatusAccepted, StatusResponse{Status: "ok"}) } func ReloadGeojson(c *gin.Context) { decoder.ReloadGeofenceAndClearStats() - c.JSON(http.StatusAccepted, map[string]interface{}{ - "status": "ok", - }) + c.JSON(http.StatusAccepted, StatusResponse{Status: "ok"}) } func PokemonScan(c *gin.Context) { @@ -489,23 +489,34 @@ func GetPokestopPositions(c *gin.Context) { func GetPokestop(c *gin.Context) { fortId := c.Param("fort_id") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - pokestop, err := decoder.GetPokestopRecord(ctx, dbDetails, fortId) - cancel() + //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + pokestop, unlock, err := decoder.PeekPokestopRecord(fortId) + if unlock != nil { + defer unlock() + } + //cancel() if err != nil { log.Warnf("GET /api/pokestop/id/:fort_id/ Error during post retrieve %v", err) c.Status(http.StatusInternalServerError) return } - c.JSON(http.StatusAccepted, pokestop) + if pokestop == nil { + c.Status(http.StatusNotFound) + return + } + result := decoder.BuildPokestopResult(pokestop) + c.JSON(http.StatusAccepted, result) } func GetGym(c *gin.Context) { gymId := c.Param("gym_id") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - gym, err := decoder.GetGymRecord(ctx, dbDetails, gymId) + gym, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, gymId) + if unlock != nil { + defer unlock() + } cancel() if err != nil { log.Warnf("GET /api/gym/id/:gym_id/ Error during post retrieve %v", err) @@ -513,7 +524,12 @@ func GetGym(c *gin.Context) { return } - c.JSON(http.StatusAccepted, gym) + if gym == nil { + c.Status(http.StatusNotFound) + return + } + result := decoder.BuildGymResult(gym) + c.JSON(http.StatusAccepted, result) } // POST /api/gym/query @@ -558,23 +574,29 @@ func GetGyms(c *gin.Context) { } if len(ids) == 0 { - c.JSON(http.StatusOK, []decoder.Gym{}) + c.JSON(http.StatusOK, []decoder.ApiGymResult{}) return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - out := make([]*decoder.Gym, 0, len(ids)) + out := make([]decoder.ApiGymResult, 0, len(ids)) for _, id := range ids { - g, err := decoder.GetGymRecord(ctx, dbDetails, id) + g, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, id) if err != nil { + if unlock != nil { + unlock() + } log.Warnf("error retrieving gym %s: %v", id, err) c.Status(http.StatusInternalServerError) return } if g != nil { - out = append(out, g) + out = append(out, decoder.BuildGymResult(g)) + } + if unlock != nil { + unlock() } if ctx.Err() != nil { c.Status(http.StatusInternalServerError) @@ -688,13 +710,16 @@ func SearchGyms(c *gin.Context) { return } - out := make([]*decoder.Gym, 0, len(ids)) + out := make([]decoder.ApiGymResult, 0, len(ids)) for _, id := range ids { if id == "" { continue } - g, err := decoder.GetGymRecord(ctx, dbDetails, id) + g, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, id) if err != nil { + if unlock != nil { + unlock() + } if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { log.Warnf("timed out while fetching %s: %v", id, err) c.Status(http.StatusGatewayTimeout) @@ -705,7 +730,10 @@ func SearchGyms(c *gin.Context) { return } if g != nil { - out = append(out, g) + out = append(out, decoder.BuildGymResult(g)) + } + if unlock != nil { + unlock() } if ctx.Err() != nil { c.Status(http.StatusInternalServerError) @@ -724,16 +752,21 @@ func GetTappable(c *gin.Context) { c.Status(http.StatusBadRequest) return } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - tappable, err := decoder.GetTappableRecord(ctx, dbDetails, tappableId) - cancel() + tappable, unlock, err := decoder.PeekTappableRecord(tappableId) + if unlock != nil { + defer unlock() + } if err != nil { log.Warnf("GET /api/tappable/id/:tappable_id/ Error during post retrieve %v", err) c.Status(http.StatusInternalServerError) return } - - c.JSON(http.StatusAccepted, tappable) + if tappable == nil { + c.Status(http.StatusNotFound) + return + } + result := decoder.BuildTappableResult(tappable) + c.JSON(http.StatusAccepted, result) } func GetDevices(c *gin.Context) { diff --git a/stats_collector/noop.go b/stats_collector/noop.go index e50163ea..1a1ff0fa 100644 --- a/stats_collector/noop.go +++ b/stats_collector/noop.go @@ -1,7 +1,7 @@ package stats_collector import ( - "gopkg.in/guregu/null.v4" + "github.com/guregu/null/v6" "golbat/geo" ) diff --git a/stats_collector/prometheus.go b/stats_collector/prometheus.go index e98ea0ae..93547984 100644 --- a/stats_collector/prometheus.go +++ b/stats_collector/prometheus.go @@ -2,13 +2,14 @@ package stats_collector import ( "database/sql" - "golbat/util" "strconv" "sync" "time" + "golbat/util" + + "github.com/guregu/null/v6" "github.com/prometheus/client_golang/prometheus" - "gopkg.in/guregu/null.v4" "golbat/geo" ) diff --git a/stats_collector/stats_collector.go b/stats_collector/stats_collector.go index de14c3f1..9481c41a 100644 --- a/stats_collector/stats_collector.go +++ b/stats_collector/stats_collector.go @@ -6,8 +6,8 @@ import ( "github.com/Depado/ginprom" "github.com/gin-gonic/gin" + "github.com/guregu/null/v6" log "github.com/sirupsen/logrus" - "gopkg.in/guregu/null.v4" ) type StatsCollector interface {