From 3740eb32bf6ebdb80229c2ffe986b54aa47350ba Mon Sep 17 00:00:00 2001 From: nathan Date: Sat, 16 May 2026 00:01:20 -0700 Subject: [PATCH] Add deterministic weather fallback --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Docs/TechnicalDesignDocument.md | 9 +++ .../verify_deterministic_weather_fallback.py | 51 +++++++++++++ .../AgrarianWeatherProviderSubsystem.cpp | 71 ++++++++++++++++++- .../AgrarianWeatherProviderSubsystem.h | 10 +++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 Scripts/verify_deterministic_weather_fallback.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index e226d6d..8eb2fad 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -430,7 +430,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [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. - [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. - [x] Map real weather inputs into Agrarian weather states: temperature, precipitation, wind, cloud cover, humidity, pressure, visibility, and weather code. Added replicated `FAgrarianMappedWeatherInputs`, provider snapshot mapping, Open-Meteo visibility derivation, NOAA/NWS grid enrichment for humidity/sky cover/pressure/visibility, and game-state application that preserves raw mapped inputs alongside the collapsed Agrarian weather state. -- [ ] Add deterministic fallback weather simulation when external weather data is unavailable. +- [x] Add deterministic fallback weather simulation when external weather data is unavailable. Added tile/day-seeded fallback snapshots for temperature, daily low/high, cloud cover, humidity, wind, pressure, precipitation, visibility, provider code, and mapped Agrarian state; fallback snapshots use the normal server cache and apply through the same mapped-weather path as live providers. - [ ] Store weather source, provider timestamp, tile coordinate, and applied in-game weather state for debugging and persistence. - [ ] Add weather save/load support. - [x] Connect weather to body temperature. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 220a696..0f39c1b 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -190,6 +190,15 @@ fills those fields directly where available; NOAA/NWS fills them from grid data where available and derives provisional visibility/weather-state values until a deeper provider-specific mapping pass is added. +Deterministic fallback weather keeps the game playable when external providers +are disabled, unreachable, or return unusable data. The fallback snapshot is +derived from tile ID and Agrarian day, then mapped through the same +`FAgrarianMappedWeatherInputs` path as live providers. It produces seasonal +daily low/high temperatures, current temperature, cloud cover, humidity, wind, +pressure, precipitation, visibility, and the collapsed Agrarian weather state. +Fallback snapshots use provider `deterministic-fallback` and are cached +server-side with the normal weather cache TTL. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/verify_deterministic_weather_fallback.py b/Scripts/verify_deterministic_weather_fallback.py new file mode 100644 index 0000000..1b18543 --- /dev/null +++ b/Scripts/verify_deterministic_weather_fallback.py @@ -0,0 +1,51 @@ +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: [ + "bEnableDeterministicFallbackWeather", + "ApplyDeterministicFallbackWeather", + "BuildDeterministicFallbackSnapshot", + "GetDeterministicWeatherNoise", + ], + CPP: [ + "return ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, GameState);", + "Provider = TEXT(\"deterministic-fallback\")", + "GetDeterministicWeatherNoise(TileId, SafeDay", + "Snapshot.PrecipitationMm", + "Snapshot.VisibilityMeters", + "CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds);", + "ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude", + ], + TDD: [ + "Deterministic fallback weather", + "tile ID and Agrarian day", + "provider `deterministic-fallback`", + ], + ROADMAP: [ + "[x] Add deterministic fallback weather simulation when external weather data is unavailable.", + ], +} + + +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("Deterministic weather fallback verification failed: " + "; ".join(missing)) + print("Agrarian deterministic weather fallback verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp index 7c667bb..b717852 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.cpp @@ -24,7 +24,7 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForActiveGameState() bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, float Latitude, float Longitude) { - if (!bEnableLiveWeatherRequests || TileId == NAME_None) + if (TileId == NAME_None) { return false; } @@ -40,6 +40,10 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, floa { return true; } + if (!bEnableLiveWeatherRequests) + { + return ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, GameState); + } const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude); TSharedRef Request = FHttpModule::Get().CreateRequest(); @@ -163,6 +167,57 @@ void UAgrarianWeatherProviderSubsystem::ClearWeatherSnapshotCache() ServerWeatherSnapshotCache.Empty(); } +bool UAgrarianWeatherProviderSubsystem::ApplyDeterministicFallbackWeather(FName TileId, float Latitude, float Longitude, AAgrarianGameState* GameState) +{ + if (!bEnableDeterministicFallbackWeather || !GameState || !GameState->HasAuthority() || TileId == NAME_None) + { + return false; + } + + const FAgrarianWeatherProviderSnapshot Snapshot = BuildDeterministicFallbackSnapshot( + TileId, + Latitude, + Longitude, + GameState->ActiveDayOfYear, + GameState->WorldHours); + LastSnapshot = Snapshot; + CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); + return ApplySnapshotToGameState(Snapshot, GameState); +} + +FAgrarianWeatherProviderSnapshot UAgrarianWeatherProviderSubsystem::BuildDeterministicFallbackSnapshot(FName TileId, float Latitude, float Longitude, int32 DayOfYear, float HourOfDay) const +{ + const int32 SafeDay = FMath::Clamp(DayOfYear, 1, 366); + const float SeasonalRadians = ((static_cast(SafeDay) - 172.0f) / 366.0f) * 2.0f * PI; + const float LatitudeSeasonScale = FMath::Clamp(FMath::Abs(Latitude) / 60.0f, 0.0f, 1.0f); + const float SeasonalTemperatureC = 12.0f + (FMath::Cos(SeasonalRadians) * 10.0f * LatitudeSeasonScale); + const float DailySwingC = FMath::Lerp(4.0f, 10.0f, LatitudeSeasonScale); + const float Noise = GetDeterministicWeatherNoise(TileId, SafeDay, 11); + const float StormNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 23); + const float CloudNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 37); + const float WindNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 53); + + FAgrarianWeatherProviderSnapshot Snapshot; + Snapshot.TileId = TileId; + Snapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); + Snapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); + Snapshot.Provider = TEXT("deterministic-fallback"); + Snapshot.ProviderTimestamp = FString::Printf(TEXT("day-%03d-hour-%02d"), SafeDay, FMath::FloorToInt(FMath::Clamp(HourOfDay, 0.0f, 24.0f))); + Snapshot.DailyLowTemperatureC = SeasonalTemperatureC - DailySwingC + FMath::Lerp(-2.0f, 2.0f, Noise); + Snapshot.DailyHighTemperatureC = SeasonalTemperatureC + DailySwingC + FMath::Lerp(-2.0f, 2.0f, Noise); + Snapshot.CurrentTemperatureC = (Snapshot.DailyLowTemperatureC + Snapshot.DailyHighTemperatureC) * 0.5f; + Snapshot.CloudCoverPercent = FMath::Clamp(CloudNoise * 100.0f, 0.0f, 100.0f); + Snapshot.RelativeHumidityPercent = FMath::Clamp(45.0f + (Snapshot.CloudCoverPercent * 0.35f) + FMath::Lerp(-10.0f, 10.0f, Noise), 10.0f, 100.0f); + Snapshot.WindSpeedKmh = FMath::Clamp(5.0f + (WindNoise * 35.0f), 0.0f, 75.0f); + Snapshot.PressureMslHpa = FMath::Clamp(1018.0f - (Snapshot.CloudCoverPercent * 0.12f) - (Snapshot.WindSpeedKmh * 0.08f), 960.0f, 1040.0f); + Snapshot.PrecipitationMm = StormNoise > 0.72f ? FMath::Lerp(0.1f, 8.0f, StormNoise) : 0.0f; + Snapshot.VisibilityMeters = FMath::Clamp(10000.0f - (Snapshot.CloudCoverPercent * 30.0f) - (Snapshot.PrecipitationMm * 300.0f), 250.0f, 10000.0f); + Snapshot.WeatherCode = Snapshot.PrecipitationMm > 0.0f ? 61 : 0; + Snapshot.MappedWeather = MapOpenMeteoWeatherCode(Snapshot.WeatherCode, Snapshot.PrecipitationMm, Snapshot.WindSpeedKmh); + Snapshot.bIsValid = true; + return Snapshot; +} + EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh) { if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f) @@ -184,6 +239,12 @@ EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode( return EAgrarianWeatherType::Clear; } +float UAgrarianWeatherProviderSubsystem::GetDeterministicWeatherNoise(FName TileId, int32 DayOfYear, int32 Salt) const +{ + const FString SeedString = FString::Printf(TEXT("%s:%d:%d"), *TileId.ToString(), DayOfYear, Salt); + return static_cast(GetTypeHash(SeedString) % 10000) / 9999.0f; +} + FString UAgrarianWeatherProviderSubsystem::MakeCacheKey(FName TileId, const FString& Provider) const { return FString::Printf(TEXT("%s:%s"), *Provider.ToLower(), *TileId.ToString()); @@ -207,12 +268,16 @@ void UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse(FHttpRequestPtr Requ { if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) { + UWorld* World = GetWorld(); + ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } FAgrarianWeatherProviderSnapshot Snapshot; if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot)) { + UWorld* World = GetWorld(); + ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } @@ -265,12 +330,16 @@ void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(FHttpRequestPt { if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) { + UWorld* World = GetWorld(); + ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } FAgrarianWeatherProviderSnapshot Snapshot; if (!ParseNoaaNwsGridData(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot)) { + UWorld* World = GetWorld(); + ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } diff --git a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h index fe9b63c..9158913 100644 --- a/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h +++ b/Source/AgrarianGame/AgrarianWeatherProviderSubsystem.h @@ -115,6 +115,9 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather", meta = (ClampMin = "60.0")) float WeatherSnapshotCacheTtlSeconds = 900.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") + bool bEnableDeterministicFallbackWeather = true; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather") FAgrarianWeatherProviderSnapshot LastSnapshot; @@ -157,9 +160,16 @@ public: UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") void ClearWeatherSnapshotCache(); + UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") + bool ApplyDeterministicFallbackWeather(FName TileId, float Latitude, float Longitude, AAgrarianGameState* GameState); + + UFUNCTION(BlueprintPure, Category = "Agrarian|Weather") + FAgrarianWeatherProviderSnapshot BuildDeterministicFallbackSnapshot(FName TileId, float Latitude, float Longitude, int32 DayOfYear, float HourOfDay) const; + static EAgrarianWeatherType MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh); private: + float GetDeterministicWeatherNoise(FName TileId, int32 DayOfYear, int32 Salt) const; 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);