From a4aa2095be9be4ac7dc2c955bc96718b9e5e2bb2 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 15 May 2026 23:08:30 -0700 Subject: [PATCH] Add tile driven weather provider adapter --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Data/Tiles/tile_weather_manifest.json | 15 ++ Docs/TechnicalDesignDocument.md | 10 + Scripts/generate_tile_weather_manifest.py | 65 +++++++ Scripts/verify_weather_provider_adapter.py | 81 ++++++++ Source/AgrarianGame/AgrarianGame.Build.cs | 2 + .../AgrarianWeatherProviderSubsystem.cpp | 183 ++++++++++++++++++ .../AgrarianWeatherProviderSubsystem.h | 101 ++++++++++ 8 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 Data/Tiles/tile_weather_manifest.json create mode 100644 Scripts/generate_tile_weather_manifest.py create mode 100644 Scripts/verify_weather_provider_adapter.py create mode 100644 Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp create mode 100644 Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index e860c2e..8b2a52d 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -425,7 +425,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add storm placeholder. - [x] Add weather transition rules. - [x] Add weather replication. -- [ ] Add real-world weather provider adapter for Ground Zero by latitude/longitude. +- [x] Add real-world weather provider adapter for Ground Zero by latitude/longitude. Added a tile-driven Open-Meteo adapter subsystem that can request weather for any active tile by center latitude/longitude, map provider weather codes into Agrarian weather states, apply current temperature and daily low/high to `AAgrarianGameState`, and generate a source-backed tile weather manifest so future real tiles become weather-eligible automatically. - [ ] Use Open-Meteo as the first global MVP weather source. - [ ] Add NOAA/NWS fallback or enrichment for US tiles where useful. - [ ] Cache real-weather snapshots server-side so clients never call public weather APIs directly. diff --git a/Data/Tiles/tile_weather_manifest.json b/Data/Tiles/tile_weather_manifest.json new file mode 100644 index 0000000..921e1cb --- /dev/null +++ b/Data/Tiles/tile_weather_manifest.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "generated_at_utc": "2026-05-16T05:58:06Z", + "source_registry": "Data/Tiles/ground_zero_tiles.json", + "generation_rule": "Every source-backed/generated tile with center coordinates is emitted; unknown placeholders are skipped.", + "tiles": [ + { + "tile_id": "gz_us_ca_pacifica_utm10n_e544_n4160", + "center_latitude": 37.5925, + "center_longitude": -122.4995, + "provider": "open-meteo", + "lookup_rule": "Use tile center latitude/longitude for live MVP weather and temperature snapshots." + } + ] +} diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 0e448f6..6f239cf 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -147,6 +147,16 @@ clients to call public weather APIs directly. This keeps real-world temperature and weather tied to the represented map tile while preserving a deterministic fallback if an external provider is unavailable. +The first real-weather adapter is `UAgrarianWeatherProviderSubsystem`. It uses +Open-Meteo forecast requests keyed by tile center latitude/longitude, parses the +current temperature, daily low/high, precipitation, wind, humidity, cloud cover, +pressure, provider timestamp, and weather code, then applies the mapped state to +`AAgrarianGameState` on the server. It is tile-driven rather than Ground-Zero +hard-coded: `Scripts/generate_tile_weather_manifest.py` emits every +source-backed, generated, validated, packaged, or published tile with center +coordinates, while placeholder/unknown tiles are skipped. Future source-backed +tiles therefore become weather-eligible when their registry entries are added. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/generate_tile_weather_manifest.py b/Scripts/generate_tile_weather_manifest.py new file mode 100644 index 0000000..f0e09be --- /dev/null +++ b/Scripts/generate_tile_weather_manifest.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Generate weather lookup coordinates for source-backed Agrarian tiles. + +The weather provider can request live conditions for any active tile with +latitude/longitude. This manifest is intentionally generated from the registry +instead of per-tile hard-coded overrides, so future source-backed tiles are +eligible automatically when they are added to the registry. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = ROOT / "Data" / "Tiles" / "ground_zero_tiles.json" +OUTPUT_PATH = ROOT / "Data" / "Tiles" / "tile_weather_manifest.json" +WEATHER_READY_STATUSES = {"source_data_found", "generated", "validated", "packaged", "published"} + + +def main() -> None: + registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + records = [] + + for tile in registry.get("tiles", []): + status = tile.get("status") + tile_id = tile.get("tile_id") + grid = tile.get("grid", {}) + latitude = grid.get("center_latitude") + longitude = grid.get("center_longitude") + if status not in WEATHER_READY_STATUSES or not tile_id or latitude is None or longitude is None: + continue + + records.append( + { + "tile_id": tile_id, + "center_latitude": latitude, + "center_longitude": longitude, + "provider": "open-meteo", + "lookup_rule": "Use tile center latitude/longitude for live MVP weather and temperature snapshots.", + } + ) + + OUTPUT_PATH.write_text( + json.dumps( + { + "schema_version": 1, + "generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source_registry": str(REGISTRY_PATH.relative_to(ROOT)), + "generation_rule": "Every source-backed/generated tile with center coordinates is emitted; unknown placeholders are skipped.", + "tiles": records, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + print(f"Wrote {len(records)} tile weather manifest record(s) to {OUTPUT_PATH.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/Scripts/verify_weather_provider_adapter.py b/Scripts/verify_weather_provider_adapter.py new file mode 100644 index 0000000..430451b --- /dev/null +++ b/Scripts/verify_weather_provider_adapter.py @@ -0,0 +1,81 @@ +from pathlib import Path +import json + + +ROOT = Path(__file__).resolve().parents[1] +BUILD_CS = ROOT / "Source" / "AgrarianGame" / "AgrarianGame.Build.cs" +HEADER = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.h" +CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.cpp" +GENERATOR = ROOT / "Scripts" / "generate_tile_weather_manifest.py" +MANIFEST = ROOT / "Data" / "Tiles" / "tile_weather_manifest.json" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + + +EXPECTED = { + BUILD_CS: ['"HTTP"', '"Json"'], + HEADER: [ + "UAgrarianWeatherProviderSubsystem", + "FAgrarianWeatherProviderSnapshot", + "RequestWeatherForActiveGameState", + "RequestWeatherForTile", + "BuildOpenMeteoForecastUrl", + "ApplySnapshotToGameState", + "MapOpenMeteoWeatherCode", + ], + CPP: [ + "current=temperature_2m,relative_humidity_2m,precipitation", + "daily=temperature_2m_max,temperature_2m_min", + "GameState->SetRegionalTemperatureProfile", + "GameState->SetRegionalObservedTemperature", + "GameState->SetWeather", + "MapOpenMeteoWeatherCode", + ], + GENERATOR: [ + "WEATHER_READY_STATUSES", + "Every source-backed/generated tile", + "tile_weather_manifest.json", + ], + TDD: [ + "Open-Meteo", + "tile center latitude/longitude", + "Future source-backed", + ], + ROADMAP: [ + "[x] Add real-world weather provider adapter for Ground Zero by latitude/longitude.", + "tile-driven Open-Meteo adapter", + ], +} + + +def assert_contains() -> None: + missing = [] + for path, snippets in EXPECTED.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + missing.append(f"{path.relative_to(ROOT)}: {snippet}") + if missing: + raise RuntimeError("Weather provider adapter verification failed: " + "; ".join(missing)) + + +def assert_manifest() -> None: + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + tiles = data.get("tiles", []) + if len(tiles) != 1: + raise RuntimeError(f"Expected one source-backed weather tile, got {len(tiles)}") + tile = tiles[0] + if tile.get("tile_id") != "gz_us_ca_pacifica_utm10n_e544_n4160": + raise RuntimeError(f"Unexpected weather tile id: {tile.get('tile_id')}") + if tile.get("center_latitude") is None or tile.get("center_longitude") is None: + raise RuntimeError("Weather manifest tile is missing coordinates") + + +def main() -> None: + assert_contains() + assert_manifest() + print("Agrarian weather provider adapter verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianGame.Build.cs b/Source/AgrarianGame/AgrarianGame.Build.cs index 5020dcc..28fd23f 100644 --- a/Source/AgrarianGame/AgrarianGame.Build.cs +++ b/Source/AgrarianGame/AgrarianGame.Build.cs @@ -17,6 +17,8 @@ public class AgrarianGame : ModuleRules "AIModule", "UMG", "Landscape", + "HTTP", + "Json", "Slate", "SlateCore" }); diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp new file mode 100644 index 0000000..827f406 --- /dev/null +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp @@ -0,0 +1,183 @@ +// Copyright Pacificao. All Rights Reserved. + +#include "AgrarianWeatherProviderSubsystem.h" +#include "AgrarianGameState.h" +#include "Dom/JsonObject.h" +#include "Engine/World.h" +#include "HttpModule.h" +#include "Interfaces/IHttpResponse.h" +#include "Kismet/GameplayStatics.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" + +bool UAgrarianWeatherProviderSubsystem::RequestWeatherForActiveGameState() +{ + UWorld* World = GetWorld(); + AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; + if (!GameState || !GameState->HasAuthority()) + { + return false; + } + + return RequestWeatherForTile(GameState->ActiveSolarTileId, GameState->ActiveTileLatitude, GameState->ActiveTileLongitude); +} + +bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, float Latitude, float Longitude) +{ + if (!bEnableLiveWeatherRequests || TileId == NAME_None) + { + return false; + } + + UWorld* World = GetWorld(); + if (!World || !World->GetAuthGameMode()) + { + return false; + } + + const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude); + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(Url); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + Request->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse, TileId, Latitude, Longitude); + return Request->ProcessRequest(); +} + +FString UAgrarianWeatherProviderSubsystem::BuildOpenMeteoForecastUrl(FName TileId, float Latitude, float Longitude) const +{ + const float ClampedLatitude = FMath::Clamp(Latitude, -90.0f, 90.0f); + const float ClampedLongitude = FMath::Clamp(Longitude, -180.0f, 180.0f); + return FString::Printf( + TEXT("%s?latitude=%.6f&longitude=%.6f¤t=temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,wind_speed_10m_max&forecast_days=1&timezone=auto"), + *OpenMeteoForecastEndpoint, + ClampedLatitude, + ClampedLongitude); +} + +bool UAgrarianWeatherProviderSubsystem::ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const +{ + if (!Snapshot.bIsValid || !GameState || !GameState->HasAuthority()) + { + return false; + } + + GameState->SetRegionalTemperatureProfile(Snapshot.DailyLowTemperatureC, Snapshot.DailyHighTemperatureC); + GameState->SetRegionalObservedTemperature( + Snapshot.CurrentTemperatureC, + 1.0f, + FString::Printf(TEXT("%s:%s"), *Snapshot.Provider, *Snapshot.ProviderTimestamp)); + GameState->SetWeather(Snapshot.MappedWeather); + return true; +} + +EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh) +{ + if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f) + { + return EAgrarianWeatherType::Storm; + } + if (WeatherCode == 71 || WeatherCode == 73 || WeatherCode == 75 || WeatherCode == 77 || WeatherCode == 85 || WeatherCode == 86) + { + return EAgrarianWeatherType::ColdWind; + } + if ((WeatherCode >= 51 && WeatherCode <= 67) || (WeatherCode >= 80 && WeatherCode <= 82) || PrecipitationMm > 0.05f) + { + return EAgrarianWeatherType::Rain; + } + if (WindSpeedKmh >= 35.0f) + { + return EAgrarianWeatherType::ColdWind; + } + return EAgrarianWeatherType::Clear; +} + +void UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude) +{ + if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) + { + return; + } + + FAgrarianWeatherProviderSnapshot Snapshot; + if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot)) + { + return; + } + + LastSnapshot = Snapshot; + + UWorld* World = GetWorld(); + AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; + ApplySnapshotToGameState(Snapshot, GameState); +} + +bool UAgrarianWeatherProviderSubsystem::ParseOpenMeteoForecast(const FString& ResponseContent, FName TileId, float Latitude, float Longitude, FAgrarianWeatherProviderSnapshot& OutSnapshot) const +{ + TSharedPtr RootObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(ResponseContent); + if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) + { + return false; + } + + const TSharedPtr* CurrentObject = nullptr; + if (!RootObject->TryGetObjectField(TEXT("current"), CurrentObject) || !CurrentObject || !CurrentObject->IsValid()) + { + return false; + } + + const TSharedPtr* DailyObject = nullptr; + if (!RootObject->TryGetObjectField(TEXT("daily"), DailyObject) || !DailyObject || !DailyObject->IsValid()) + { + return false; + } + + const TArray>* DailyLowValues = nullptr; + const TArray>* DailyHighValues = nullptr; + if (!(*DailyObject)->TryGetArrayField(TEXT("temperature_2m_min"), DailyLowValues) + || !(*DailyObject)->TryGetArrayField(TEXT("temperature_2m_max"), DailyHighValues) + || !DailyLowValues || !DailyHighValues || DailyLowValues->Num() == 0 || DailyHighValues->Num() == 0) + { + return false; + } + + double CurrentTemperature = 0.0; + double RelativeHumidity = 0.0; + double Precipitation = 0.0; + double Rain = 0.0; + double Showers = 0.0; + double Snowfall = 0.0; + double WeatherCode = 0.0; + double CloudCover = 0.0; + double Pressure = 0.0; + double WindSpeed = 0.0; + (*CurrentObject)->TryGetNumberField(TEXT("temperature_2m"), CurrentTemperature); + (*CurrentObject)->TryGetNumberField(TEXT("relative_humidity_2m"), RelativeHumidity); + (*CurrentObject)->TryGetNumberField(TEXT("precipitation"), Precipitation); + (*CurrentObject)->TryGetNumberField(TEXT("rain"), Rain); + (*CurrentObject)->TryGetNumberField(TEXT("showers"), Showers); + (*CurrentObject)->TryGetNumberField(TEXT("snowfall"), Snowfall); + (*CurrentObject)->TryGetNumberField(TEXT("weather_code"), WeatherCode); + (*CurrentObject)->TryGetNumberField(TEXT("cloud_cover"), CloudCover); + (*CurrentObject)->TryGetNumberField(TEXT("pressure_msl"), Pressure); + (*CurrentObject)->TryGetNumberField(TEXT("wind_speed_10m"), WindSpeed); + + OutSnapshot.TileId = TileId; + OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); + OutSnapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); + OutSnapshot.Provider = TEXT("open-meteo"); + (*CurrentObject)->TryGetStringField(TEXT("time"), OutSnapshot.ProviderTimestamp); + OutSnapshot.CurrentTemperatureC = static_cast(CurrentTemperature); + OutSnapshot.DailyLowTemperatureC = static_cast((*DailyLowValues)[0]->AsNumber()); + OutSnapshot.DailyHighTemperatureC = static_cast((*DailyHighValues)[0]->AsNumber()); + OutSnapshot.PrecipitationMm = static_cast(FMath::Max(Precipitation, FMath::Max(Rain + Showers, Snowfall))); + OutSnapshot.WindSpeedKmh = static_cast(WindSpeed); + OutSnapshot.CloudCoverPercent = static_cast(CloudCover); + OutSnapshot.RelativeHumidityPercent = static_cast(RelativeHumidity); + OutSnapshot.PressureMslHpa = static_cast(Pressure); + OutSnapshot.WeatherCode = FMath::RoundToInt(WeatherCode); + OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh); + OutSnapshot.bIsValid = true; + return true; +} diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h new file mode 100644 index 0000000..6ccf6b7 --- /dev/null +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h @@ -0,0 +1,101 @@ +// Copyright Pacificao. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "HttpFwd.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "AgrarianTypes.h" +#include "AgrarianWeatherProviderSubsystem.generated.h" + +class AAgrarianGameState; +class IHttpRequest; +class IHttpResponse; + +USTRUCT(BlueprintType) +struct FAgrarianWeatherProviderSnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FName TileId = NAME_None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float Latitude = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float Longitude = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FString Provider = TEXT("open-meteo"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FString ProviderTimestamp; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float CurrentTemperatureC = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float DailyLowTemperatureC = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float DailyHighTemperatureC = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float PrecipitationMm = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float WindSpeedKmh = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float CloudCoverPercent = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float RelativeHumidityPercent = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float PressureMslHpa = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + int32 WeatherCode = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + EAgrarianWeatherType MappedWeather = EAgrarianWeatherType::Clear; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + bool bIsValid = false; +}; + +UCLASS() +class UAgrarianWeatherProviderSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + bool bEnableLiveWeatherRequests = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FString OpenMeteoForecastEndpoint = TEXT("https://api.open-meteo.com/v1/forecast"); + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") + FAgrarianWeatherProviderSnapshot LastSnapshot; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool RequestWeatherForActiveGameState(); + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool RequestWeatherForTile(FName TileId, float Latitude, float Longitude); + + UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") + FString BuildOpenMeteoForecastUrl(FName TileId, float Latitude, float Longitude) const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const; + + static EAgrarianWeatherType MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh); + +private: + void OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude); + bool ParseOpenMeteoForecast(const FString& ResponseContent, FName TileId, float Latitude, float Longitude, FAgrarianWeatherProviderSnapshot& OutSnapshot) const; +};