Cache real weather snapshots server side

This commit is contained in:
2026-05-15 23:34:29 -07:00
parent 5a90d532e2
commit 8ae5ecb3b0
5 changed files with 161 additions and 1 deletions
+1 -1
View File
@@ -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.
+8
View File
@@ -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
+52
View File
@@ -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);