Add deterministic weather fallback

This commit is contained in:
2026-05-16 00:01:20 -07:00
parent ff6fc61af3
commit 3740eb32bf
5 changed files with 141 additions and 2 deletions
+1 -1
View File
@@ -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.
+9
View File
@@ -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
@@ -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()
@@ -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<IHttpRequest, ESPMode::ThreadSafe> 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<float>(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<float>(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<AAgrarianGameState>() : nullptr);
return;
}
FAgrarianWeatherProviderSnapshot Snapshot;
if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
{
UWorld* World = GetWorld();
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : 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<AAgrarianGameState>() : nullptr);
return;
}
FAgrarianWeatherProviderSnapshot Snapshot;
if (!ParseNoaaNwsGridData(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
{
UWorld* World = GetWorld();
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : nullptr);
return;
}
@@ -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);