Add deterministic weather fallback
This commit is contained in:
@@ -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] 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] 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.
|
- [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.
|
- [ ] Store weather source, provider timestamp, tile coordinate, and applied in-game weather state for debugging and persistence.
|
||||||
- [ ] Add weather save/load support.
|
- [ ] Add weather save/load support.
|
||||||
- [x] Connect weather to body temperature.
|
- [x] Connect weather to body temperature.
|
||||||
|
|||||||
@@ -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
|
where available and derives provisional visibility/weather-state values until a
|
||||||
deeper provider-specific mapping pass is added.
|
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
|
## Terrain And Tile Delivery
|
||||||
|
|
||||||
### MVP Tile
|
### 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)
|
bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, float Latitude, float Longitude)
|
||||||
{
|
{
|
||||||
if (!bEnableLiveWeatherRequests || TileId == NAME_None)
|
if (TileId == NAME_None)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,10 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, floa
|
|||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!bEnableLiveWeatherRequests)
|
||||||
|
{
|
||||||
|
return ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, GameState);
|
||||||
|
}
|
||||||
|
|
||||||
const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude);
|
const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude);
|
||||||
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
|
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
|
||||||
@@ -163,6 +167,57 @@ void UAgrarianWeatherProviderSubsystem::ClearWeatherSnapshotCache()
|
|||||||
ServerWeatherSnapshotCache.Empty();
|
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)
|
EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh)
|
||||||
{
|
{
|
||||||
if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f)
|
if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f)
|
||||||
@@ -184,6 +239,12 @@ EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(
|
|||||||
return EAgrarianWeatherType::Clear;
|
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
|
FString UAgrarianWeatherProviderSubsystem::MakeCacheKey(FName TileId, const FString& Provider) const
|
||||||
{
|
{
|
||||||
return FString::Printf(TEXT("%s:%s"), *Provider.ToLower(), *TileId.ToString());
|
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)
|
if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
|
||||||
{
|
{
|
||||||
|
UWorld* World = GetWorld();
|
||||||
|
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : nullptr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FAgrarianWeatherProviderSnapshot Snapshot;
|
FAgrarianWeatherProviderSnapshot Snapshot;
|
||||||
if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
|
if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
|
||||||
{
|
{
|
||||||
|
UWorld* World = GetWorld();
|
||||||
|
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : nullptr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +330,16 @@ void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(FHttpRequestPt
|
|||||||
{
|
{
|
||||||
if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
|
if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
|
||||||
{
|
{
|
||||||
|
UWorld* World = GetWorld();
|
||||||
|
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : nullptr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FAgrarianWeatherProviderSnapshot Snapshot;
|
FAgrarianWeatherProviderSnapshot Snapshot;
|
||||||
if (!ParseNoaaNwsGridData(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
|
if (!ParseNoaaNwsGridData(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot))
|
||||||
{
|
{
|
||||||
|
UWorld* World = GetWorld();
|
||||||
|
ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState<AAgrarianGameState>() : nullptr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather", meta = (ClampMin = "60.0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather", meta = (ClampMin = "60.0"))
|
||||||
float WeatherSnapshotCacheTtlSeconds = 900.0f;
|
float WeatherSnapshotCacheTtlSeconds = 900.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
|
||||||
|
bool bEnableDeterministicFallbackWeather = true;
|
||||||
|
|
||||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather")
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather")
|
||||||
FAgrarianWeatherProviderSnapshot LastSnapshot;
|
FAgrarianWeatherProviderSnapshot LastSnapshot;
|
||||||
|
|
||||||
@@ -157,9 +160,16 @@ public:
|
|||||||
UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather")
|
UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather")
|
||||||
void ClearWeatherSnapshotCache();
|
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);
|
static EAgrarianWeatherType MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
float GetDeterministicWeatherNoise(FName TileId, int32 DayOfYear, int32 Salt) const;
|
||||||
FString MakeCacheKey(FName TileId, const FString& Provider) const;
|
FString MakeCacheKey(FName TileId, const FString& Provider) const;
|
||||||
void CacheSnapshot(const FAgrarianWeatherProviderSnapshot& Snapshot, float TimeToLiveSeconds);
|
void CacheSnapshot(const FAgrarianWeatherProviderSnapshot& Snapshot, float TimeToLiveSeconds);
|
||||||
void OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude);
|
void OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude);
|
||||||
|
|||||||
Reference in New Issue
Block a user