From 8ae5ecb3b06464f70dcdd7ad987564a52b03250b Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 15 May 2026 23:34:29 -0700 Subject: [PATCH] Cache real weather snapshots server side --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Docs/TechnicalDesignDocument.md | 8 +++ Scripts/verify_weather_snapshot_cache.py | 52 ++++++++++++++++ .../AgrarianWeatherProviderSubsystem.cpp | 61 +++++++++++++++++++ .../AgrarianWeatherProviderSubsystem.h | 39 ++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 Scripts/verify_weather_snapshot_cache.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index ac50277..1cbadc8 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -428,7 +428,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [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. - [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. +- [x] Cache real-weather snapshots server-side so clients never call public weather APIs directly. Added provider/tile-keyed server cache entries with TTL, cache reuse before Open-Meteo or NOAA/NWS requests, cache clearing/debug helpers, and documentation that clients consume only replicated game-state weather and temperature. - [ ] 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. - [ ] Store weather source, provider timestamp, tile coordinate, and applied in-game weather state for debugging and persistence. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 5c51a13..d443a9b 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -174,6 +174,14 @@ 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. +Real-weather snapshots are cached server-side in +`UAgrarianWeatherProviderSubsystem::ServerWeatherSnapshotCache`. Cache keys use +provider plus tile ID, and the default TTL is 15 minutes. Server requests first +try to apply a fresh cached snapshot to `AAgrarianGameState`; only cache misses +call Open-Meteo or NOAA/NWS. Clients never call Open-Meteo or NOAA/NWS directly. +They receive weather, temperature, source, and state through replicated game +state fields. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/verify_weather_snapshot_cache.py b/Scripts/verify_weather_snapshot_cache.py new file mode 100644 index 0000000..1949fe1 --- /dev/null +++ b/Scripts/verify_weather_snapshot_cache.py @@ -0,0 +1,52 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +HEADER = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.h" +CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.cpp" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + + +EXPECTED = { + HEADER: [ + "FAgrarianCachedWeatherSnapshot", + "WeatherSnapshotCacheTtlSeconds", + "ServerWeatherSnapshotCache", + "TryApplyCachedSnapshot", + "HasFreshCachedSnapshot", + "ClearWeatherSnapshotCache", + ], + CPP: [ + "TryApplyCachedSnapshot(TileId, TEXT(\"open-meteo\"), GameState)", + "TryApplyCachedSnapshot(TileId, TEXT(\"noaa-nws\"), GameState)", + "void UAgrarianWeatherProviderSubsystem::CacheSnapshot", + "FString UAgrarianWeatherProviderSubsystem::MakeCacheKey", + "CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds);", + "return ApplySnapshotToGameState(CachedSnapshot->Snapshot, GameState);", + ], + TDD: [ + "Real-weather snapshots are cached server-side", + "Clients never call Open-Meteo or NOAA/NWS directly", + "ServerWeatherSnapshotCache", + ], + ROADMAP: [ + "[x] Cache real-weather snapshots server-side so clients never call public weather APIs directly.", + ], +} + + +def main() -> 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 snapshot cache verification failed: " + "; ".join(missing)) + print("Agrarian weather snapshot cache verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp index c43989c..ddc7ba4 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp @@ -35,6 +35,12 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, floa return false; } + AAgrarianGameState* GameState = World->GetGameState(); + if (TryApplyCachedSnapshot(TileId, TEXT("open-meteo"), GameState)) + { + return true; + } + const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude); TSharedRef Request = FHttpModule::Get().CreateRequest(); Request->SetURL(Url); @@ -69,6 +75,12 @@ bool UAgrarianWeatherProviderSubsystem::RequestNoaaNwsFallbackForTile(FName Tile return false; } + AAgrarianGameState* GameState = World->GetGameState(); + if (TryApplyCachedSnapshot(TileId, TEXT("noaa-nws"), GameState)) + { + return true; + } + TSharedRef Request = FHttpModule::Get().CreateRequest(); Request->SetURL(BuildNoaaNwsPointsUrl(Latitude, Longitude)); Request->SetVerb(TEXT("GET")); @@ -108,6 +120,34 @@ bool UAgrarianWeatherProviderSubsystem::ApplySnapshotToGameState(const FAgrarian return true; } +bool UAgrarianWeatherProviderSubsystem::TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState) +{ + if (!GameState || !GameState->HasAuthority()) + { + return false; + } + + const FAgrarianCachedWeatherSnapshot* CachedSnapshot = ServerWeatherSnapshotCache.Find(MakeCacheKey(TileId, Provider)); + if (!CachedSnapshot || !CachedSnapshot->IsFresh(FDateTime::UtcNow())) + { + return false; + } + + LastSnapshot = CachedSnapshot->Snapshot; + return ApplySnapshotToGameState(CachedSnapshot->Snapshot, GameState); +} + +bool UAgrarianWeatherProviderSubsystem::HasFreshCachedSnapshot(FName TileId, const FString& Provider) const +{ + const FAgrarianCachedWeatherSnapshot* CachedSnapshot = ServerWeatherSnapshotCache.Find(MakeCacheKey(TileId, Provider)); + return CachedSnapshot && CachedSnapshot->IsFresh(FDateTime::UtcNow()); +} + +void UAgrarianWeatherProviderSubsystem::ClearWeatherSnapshotCache() +{ + ServerWeatherSnapshotCache.Empty(); +} + EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh) { if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f) @@ -129,6 +169,25 @@ EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode( return EAgrarianWeatherType::Clear; } +FString UAgrarianWeatherProviderSubsystem::MakeCacheKey(FName TileId, const FString& Provider) const +{ + return FString::Printf(TEXT("%s:%s"), *Provider.ToLower(), *TileId.ToString()); +} + +void UAgrarianWeatherProviderSubsystem::CacheSnapshot(const FAgrarianWeatherProviderSnapshot& Snapshot, float TimeToLiveSeconds) +{ + if (!Snapshot.bIsValid || Snapshot.TileId == NAME_None || Snapshot.Provider.IsEmpty()) + { + return; + } + + FAgrarianCachedWeatherSnapshot CachedSnapshot; + CachedSnapshot.Snapshot = Snapshot; + CachedSnapshot.CachedAtUtc = FDateTime::UtcNow(); + CachedSnapshot.TimeToLiveSeconds = FMath::Max(60.0f, TimeToLiveSeconds); + ServerWeatherSnapshotCache.Add(MakeCacheKey(Snapshot.TileId, Snapshot.Provider), CachedSnapshot); +} + 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) @@ -143,6 +202,7 @@ void UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse(FHttpRequestPtr Requ } LastSnapshot = Snapshot; + CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); UWorld* World = GetWorld(); AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; @@ -200,6 +260,7 @@ void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(FHttpRequestPt } LastSnapshot = Snapshot; + CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); UWorld* World = GetWorld(); AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h index 4af9c94..1dd89e2 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h @@ -66,6 +66,28 @@ struct FAgrarianWeatherProviderSnapshot bool bIsValid = false; }; +USTRUCT(BlueprintType) +struct FAgrarianCachedWeatherSnapshot +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FAgrarianWeatherProviderSnapshot Snapshot; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + FDateTime CachedAtUtc = FDateTime::MinValue(); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + float TimeToLiveSeconds = 900.0f; + + bool IsFresh(const FDateTime& NowUtc) const + { + return Snapshot.bIsValid + && CachedAtUtc != FDateTime::MinValue() + && (NowUtc - CachedAtUtc).GetTotalSeconds() <= TimeToLiveSeconds; + } +}; + UCLASS() class UAgrarianWeatherProviderSubsystem : public UGameInstanceSubsystem { @@ -87,12 +109,18 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") FString NoaaNwsUserAgent = TEXT("AgrarianGameMVP/0.1"); + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather", meta = (ClampMin = "60.0")) + float WeatherSnapshotCacheTtlSeconds = 900.0f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") FAgrarianWeatherProviderSnapshot LastSnapshot; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") FString LastNoaaNwsForecastGridDataUrl; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") + TMap ServerWeatherSnapshotCache; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") bool RequestWeatherForActiveGameState(); @@ -114,9 +142,20 @@ public: UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") bool ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState); + + UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") + bool HasFreshCachedSnapshot(FName TileId, const FString& Provider) const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + void ClearWeatherSnapshotCache(); + static EAgrarianWeatherType MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh); private: + FString MakeCacheKey(FName TileId, const FString& Provider) const; + void CacheSnapshot(const FAgrarianWeatherProviderSnapshot& Snapshot, float TimeToLiveSeconds); 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);