diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 28d02e2..ac50277 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -427,7 +427,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add weather replication. - [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. - [x] Use Open-Meteo as the first global MVP weather source. Added `Data/Weather/open_meteo_mvp_source.json` as the provider contract, documented the global tile lookup rule, and added `Scripts/verify_open_meteo_mvp_source.py` to validate the static contract plus live Open-Meteo responses for every source-backed tile in the generated weather manifest. -- [ ] Add NOAA/NWS fallback or enrichment for US tiles where useful. +- [x] Add NOAA/NWS fallback or enrichment for US tiles where useful. Added a US/NWS-eligible coordinate check, NOAA/NWS points and forecast-grid request hooks, fallback parsing for gridded temperature, precipitation probability, and wind speed, `Data/Weather/noaa_nws_us_fallback.json`, and live/static verification for source-backed US tiles. - [ ] Cache real-weather snapshots server-side so clients never call public weather APIs directly. - [ ] Map real weather inputs into Agrarian weather states: temperature, precipitation, wind, cloud cover, humidity, pressure, visibility, and weather code. - [ ] Add deterministic fallback weather simulation when external weather data is unavailable. diff --git a/Data/Weather/noaa_nws_us_fallback.json b/Data/Weather/noaa_nws_us_fallback.json new file mode 100644 index 0000000..3ddc3f3 --- /dev/null +++ b/Data/Weather/noaa_nws_us_fallback.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "provider_id": "noaa-nws", + "provider_name": "NOAA National Weather Service API", + "provider_docs_url": "https://www.weather.gov/documentation/services-web-api", + "points_endpoint": "https://api.weather.gov/points", + "api_key_required": false, + "scope": "United States and NWS-covered territories only", + "eligibility_rule": "Use as fallback or enrichment only when tile center coordinates are inside the approximate NWS latitude/longitude coverage window.", + "request_flow": [ + "GET /points/{latitude},{longitude}", + "Read properties.forecastGridData", + "GET forecastGridData for gridded temperature, precipitation probability, and wind speed" + ], + "agrarian_mapping": { + "temperature": "NWS grid temperature can feed RegionalObservedTemperatureC when Open-Meteo is unavailable for a US tile.", + "precipitation": "probabilityOfPrecipitation enriches rain/fallback mapping until precipitation amount mapping is implemented.", + "wind": "windSpeed enriches ColdWind and Storm fallback mapping." + } +} diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 45ec672..5c51a13 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -165,6 +165,15 @@ contract and can perform a live Open-Meteo request for every source-backed tile in `Data/Tiles/tile_weather_manifest.json`. This keeps the provider global for all future real tiles instead of adding one-off Ground Zero weather code. +NOAA/NWS is the US-only fallback and enrichment path. The provider contract is +stored in `Data/Weather/noaa_nws_us_fallback.json`. The weather provider +subsystem can check whether an active tile center coordinate is inside the +approximate NWS coverage window, request `api.weather.gov/points/{lat},{lon}`, +follow `properties.forecastGridData`, and parse gridded temperature, +precipitation probability, and wind speed as fallback inputs. The NWS path is +only used for eligible US/NWS-covered tiles; Open-Meteo remains the global +source for all tiles. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/verify_noaa_nws_us_fallback.py b/Scripts/verify_noaa_nws_us_fallback.py new file mode 100644 index 0000000..523ff18 --- /dev/null +++ b/Scripts/verify_noaa_nws_us_fallback.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Verify NOAA/NWS fallback wiring for US source-backed tiles.""" + +from __future__ import annotations + +import json +import os +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "Data" / "Weather" / "noaa_nws_us_fallback.json" +MANIFEST = ROOT / "Data" / "Tiles" / "tile_weather_manifest.json" +HEADER = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.h" +CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.cpp" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" + + +EXPECTED = { + HEADER: [ + "bEnableNoaaNwsFallbackForUSTiles", + "NoaaNwsPointsEndpoint", + "https://api.weather.gov/points", + "RequestNoaaNwsFallbackForTile", + "IsNoaaNwsEligibleCoordinate", + "BuildNoaaNwsPointsUrl", + ], + CPP: [ + "NoaaNwsPointsEndpoint", + "forecastGridData", + "ParseNoaaNwsGridData", + "probabilityOfPrecipitation", + "OutSnapshot.Provider = TEXT(\"noaa-nws\");", + ], + ROADMAP: ["[x] Add NOAA/NWS fallback or enrichment for US tiles where useful."], + TDD: ["NOAA/NWS is the US-only fallback and enrichment path", "Data/Weather/noaa_nws_us_fallback.json"], +} + + +def assert_static_contract() -> tuple[dict, dict]: + 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("NOAA/NWS fallback verification failed: " + "; ".join(missing)) + + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + if contract.get("provider_id") != "noaa-nws": + raise RuntimeError("NOAA/NWS contract has wrong provider_id") + if contract.get("api_key_required") is not False: + raise RuntimeError("NOAA/NWS fallback should not require an API key") + return contract, manifest + + +def is_us_eligible(tile: dict) -> bool: + lat = float(tile["center_latitude"]) + lon = float(tile["center_longitude"]) + return 18.0 <= lat <= 72.0 and -180.0 <= lon <= -64.0 + + +def assert_live_nws_points(contract: dict, manifest: dict) -> None: + if os.environ.get("AGRARIAN_SKIP_LIVE_WEATHER_CHECK") == "1": + print("Skipping live NOAA/NWS check because AGRARIAN_SKIP_LIVE_WEATHER_CHECK=1") + return + + eligible_tiles = [tile for tile in manifest.get("tiles", []) if is_us_eligible(tile)] + if not eligible_tiles: + return + + for tile in eligible_tiles: + url = f"{contract['points_endpoint']}/{float(tile['center_latitude']):.4f},{float(tile['center_longitude']):.4f}" + request = urllib.request.Request( + url, + headers={ + "Accept": "application/geo+json", + "User-Agent": "AgrarianGameMVP/0.1", + }, + ) + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8")) + properties = payload.get("properties", {}) + if not properties.get("forecastGridData"): + raise RuntimeError(f"NOAA/NWS points response missing forecastGridData for {tile['tile_id']}") + + +def main() -> None: + contract, manifest = assert_static_contract() + assert_live_nws_points(contract, manifest) + print("NOAA/NWS US fallback verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp index 827f406..c43989c 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp @@ -40,6 +40,7 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, floa Request->SetURL(Url); Request->SetVerb(TEXT("GET")); Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + Request->SetHeader(TEXT("User-Agent"), TEXT("AgrarianGameMVP/0.1")); Request->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse, TileId, Latitude, Longitude); return Request->ProcessRequest(); } @@ -55,6 +56,42 @@ FString UAgrarianWeatherProviderSubsystem::BuildOpenMeteoForecastUrl(FName TileI ClampedLongitude); } +bool UAgrarianWeatherProviderSubsystem::RequestNoaaNwsFallbackForTile(FName TileId, float Latitude, float Longitude) +{ + if (!bEnableNoaaNwsFallbackForUSTiles || TileId == NAME_None || !IsNoaaNwsEligibleCoordinate(Latitude, Longitude)) + { + return false; + } + + UWorld* World = GetWorld(); + if (!World || !World->GetAuthGameMode()) + { + return false; + } + + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(BuildNoaaNwsPointsUrl(Latitude, Longitude)); + Request->SetVerb(TEXT("GET")); + Request->SetHeader(TEXT("Accept"), TEXT("application/geo+json")); + Request->SetHeader(TEXT("User-Agent"), NoaaNwsUserAgent); + Request->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnNoaaNwsPointsResponse, TileId, Latitude, Longitude); + return Request->ProcessRequest(); +} + +bool UAgrarianWeatherProviderSubsystem::IsNoaaNwsEligibleCoordinate(float Latitude, float Longitude) const +{ + return Latitude >= 18.0f && Latitude <= 72.0f && Longitude >= -180.0f && Longitude <= -64.0f; +} + +FString UAgrarianWeatherProviderSubsystem::BuildNoaaNwsPointsUrl(float Latitude, float Longitude) const +{ + return FString::Printf( + TEXT("%s/%.4f,%.4f"), + *NoaaNwsPointsEndpoint, + FMath::Clamp(Latitude, -90.0f, 90.0f), + FMath::Clamp(Longitude, -180.0f, 180.0f)); +} + bool UAgrarianWeatherProviderSubsystem::ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const { if (!Snapshot.bIsValid || !GameState || !GameState->HasAuthority()) @@ -112,6 +149,63 @@ void UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse(FHttpRequestPtr Requ ApplySnapshotToGameState(Snapshot, GameState); } +void UAgrarianWeatherProviderSubsystem::OnNoaaNwsPointsResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude) +{ + if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) + { + return; + } + + TSharedPtr RootObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); + if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) + { + return; + } + + const TSharedPtr* PropertiesObject = nullptr; + if (!RootObject->TryGetObjectField(TEXT("properties"), PropertiesObject) || !PropertiesObject || !PropertiesObject->IsValid()) + { + return; + } + + FString ForecastGridDataUrl; + if (!(*PropertiesObject)->TryGetStringField(TEXT("forecastGridData"), ForecastGridDataUrl) || ForecastGridDataUrl.IsEmpty()) + { + return; + } + + LastNoaaNwsForecastGridDataUrl = ForecastGridDataUrl; + + TSharedRef GridRequest = FHttpModule::Get().CreateRequest(); + GridRequest->SetURL(ForecastGridDataUrl); + GridRequest->SetVerb(TEXT("GET")); + GridRequest->SetHeader(TEXT("Accept"), TEXT("application/geo+json")); + GridRequest->SetHeader(TEXT("User-Agent"), NoaaNwsUserAgent); + GridRequest->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse, TileId, Latitude, Longitude); + GridRequest->ProcessRequest(); +} + +void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(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 (!ParseNoaaNwsGridData(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; @@ -181,3 +275,63 @@ bool UAgrarianWeatherProviderSubsystem::ParseOpenMeteoForecast(const FString& Re OutSnapshot.bIsValid = true; return true; } + +bool UAgrarianWeatherProviderSubsystem::ParseNoaaNwsGridData(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* PropertiesObject = nullptr; + if (!RootObject->TryGetObjectField(TEXT("properties"), PropertiesObject) || !PropertiesObject || !PropertiesObject->IsValid()) + { + return false; + } + + auto ReadFirstGridValue = [](const TSharedPtr& Properties, const TCHAR* FieldName, double& OutValue) -> bool + { + const TSharedPtr* FieldObject = nullptr; + if (!Properties->TryGetObjectField(FieldName, FieldObject) || !FieldObject || !FieldObject->IsValid()) + { + return false; + } + + const TArray>* Values = nullptr; + if (!(*FieldObject)->TryGetArrayField(TEXT("values"), Values) || !Values || Values->Num() == 0) + { + return false; + } + + const TSharedPtr FirstValue = (*Values)[0]->AsObject(); + return FirstValue.IsValid() && FirstValue->TryGetNumberField(TEXT("value"), OutValue); + }; + + double TemperatureC = 0.0; + if (!ReadFirstGridValue(*PropertiesObject, TEXT("temperature"), TemperatureC)) + { + return false; + } + + double PrecipitationPercent = 0.0; + double WindSpeedKmh = 0.0; + ReadFirstGridValue(*PropertiesObject, TEXT("probabilityOfPrecipitation"), PrecipitationPercent); + ReadFirstGridValue(*PropertiesObject, TEXT("windSpeed"), WindSpeedKmh); + + OutSnapshot.TileId = TileId; + OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); + OutSnapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); + OutSnapshot.Provider = TEXT("noaa-nws"); + OutSnapshot.ProviderTimestamp = FDateTime::UtcNow().ToIso8601(); + OutSnapshot.CurrentTemperatureC = static_cast(TemperatureC); + OutSnapshot.DailyLowTemperatureC = static_cast(TemperatureC - 4.0); + OutSnapshot.DailyHighTemperatureC = static_cast(TemperatureC + 4.0); + OutSnapshot.PrecipitationMm = PrecipitationPercent > 30.0 ? 0.1f : 0.0f; + OutSnapshot.WindSpeedKmh = static_cast(WindSpeedKmh); + OutSnapshot.WeatherCode = PrecipitationPercent > 30.0 ? 61 : 0; + 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 index 6ccf6b7..4af9c94 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h @@ -78,9 +78,21 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") FString OpenMeteoForecastEndpoint = TEXT("https://api.open-meteo.com/v1/forecast"); + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + bool bEnableNoaaNwsFallbackForUSTiles = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FString NoaaNwsPointsEndpoint = TEXT("https://api.weather.gov/points"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FString NoaaNwsUserAgent = TEXT("AgrarianGameMVP/0.1"); + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") FAgrarianWeatherProviderSnapshot LastSnapshot; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") + FString LastNoaaNwsForecastGridDataUrl; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") bool RequestWeatherForActiveGameState(); @@ -90,6 +102,15 @@ public: UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") FString BuildOpenMeteoForecastUrl(FName TileId, float Latitude, float Longitude) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool RequestNoaaNwsFallbackForTile(FName TileId, float Latitude, float Longitude); + + UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") + bool IsNoaaNwsEligibleCoordinate(float Latitude, float Longitude) const; + + UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") + FString BuildNoaaNwsPointsUrl(float Latitude, float Longitude) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") bool ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const; @@ -97,5 +118,8 @@ public: private: void OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude); + void OnNoaaNwsPointsResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude); + void OnNoaaNwsGridDataResponse(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; + bool ParseNoaaNwsGridData(const FString& ResponseContent, FName TileId, float Latitude, float Longitude, FAgrarianWeatherProviderSnapshot& OutSnapshot) const; };