Cache real weather snapshots server side
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -35,6 +35,12 @@ bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, floa
|
||||
return false;
|
||||
}
|
||||
|
||||
AAgrarianGameState* GameState = World->GetGameState<AAgrarianGameState>();
|
||||
if (TryApplyCachedSnapshot(TileId, TEXT("open-meteo"), GameState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude);
|
||||
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
|
||||
Request->SetURL(Url);
|
||||
@@ -69,6 +75,12 @@ bool UAgrarianWeatherProviderSubsystem::RequestNoaaNwsFallbackForTile(FName Tile
|
||||
return false;
|
||||
}
|
||||
|
||||
AAgrarianGameState* GameState = World->GetGameState<AAgrarianGameState>();
|
||||
if (TryApplyCachedSnapshot(TileId, TEXT("noaa-nws"), GameState))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> 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<AAgrarianGameState>() : nullptr;
|
||||
@@ -200,6 +260,7 @@ void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(FHttpRequestPt
|
||||
}
|
||||
|
||||
LastSnapshot = Snapshot;
|
||||
CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds);
|
||||
|
||||
UWorld* World = GetWorld();
|
||||
AAgrarianGameState* GameState = World ? World->GetGameState<AAgrarianGameState>() : nullptr;
|
||||
|
||||
@@ -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<FString, FAgrarianCachedWeatherSnapshot> 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);
|
||||
|
||||
Reference in New Issue
Block a user