Map provider weather inputs

This commit is contained in:
2026-05-15 23:47:35 -07:00
parent 8ae5ecb3b0
commit ff6fc61af3
8 changed files with 204 additions and 7 deletions
+1 -1
View File
@@ -429,7 +429,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
- [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] 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. - [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.
- [ ] Map real weather inputs into Agrarian weather states: temperature, precipitation, wind, cloud cover, humidity, pressure, visibility, and weather code. - [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. - [ ] 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. - [ ] 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.
+8
View File
@@ -182,6 +182,14 @@ 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 They receive weather, temperature, source, and state through replicated game
state fields. state fields.
Real-weather provider values are mapped into `FAgrarianMappedWeatherInputs`
before they affect gameplay. The mapped snapshot keeps temperature,
precipitation, wind, cloud cover, humidity, pressure, visibility, and provider
weather code available alongside the collapsed Agrarian weather state. Open-Meteo
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.
## Terrain And Tile Delivery ## Terrain And Tile Delivery
### MVP Tile ### MVP Tile
+73
View File
@@ -0,0 +1,73 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TYPES_H = ROOT / "Source" / "AgrarianGame" / "AgrarianTypes.h"
GAME_STATE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.h"
GAME_STATE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.cpp"
PROVIDER_H = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.h"
PROVIDER_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianWeatherProviderSubsystem.cpp"
TDD = ROOT / "Docs" / "TechnicalDesignDocument.md"
ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md"
EXPECTED = {
TYPES_H: [
"FAgrarianMappedWeatherInputs",
"TemperatureC",
"PrecipitationMm",
"WindSpeedKmh",
"CloudCoverPercent",
"RelativeHumidityPercent",
"PressureMslHpa",
"VisibilityMeters",
"ProviderWeatherCode",
],
GAME_STATE_H: [
"FAgrarianMappedWeatherInputs ActiveWeatherInputs",
"ApplyMappedWeatherInputs",
],
GAME_STATE_CPP: [
"DOREPLIFETIME(AAgrarianGameState, ActiveWeatherInputs);",
"void AAgrarianGameState::ApplyMappedWeatherInputs",
"SetRegionalTemperatureProfile",
"SetRegionalObservedTemperature",
"SetWeather(ActiveWeatherInputs.MappedWeather)",
],
PROVIDER_H: [
"VisibilityMeters",
"MapSnapshotToAgrarianWeatherInputs",
],
PROVIDER_CPP: [
"FAgrarianMappedWeatherInputs UAgrarianWeatherProviderSubsystem::MapSnapshotToAgrarianWeatherInputs",
"GameState->ApplyMappedWeatherInputs",
"OutSnapshot.VisibilityMeters",
"relativeHumidity",
"skyCover",
"pressure",
"visibility",
],
TDD: [
"Real-weather provider values are mapped into `FAgrarianMappedWeatherInputs`",
"weather code available alongside the collapsed Agrarian weather state",
],
ROADMAP: [
"[x] Map real weather inputs into Agrarian weather states: temperature, precipitation, wind, cloud cover, humidity, pressure, visibility, and weather code.",
],
}
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 input mapping verification failed: " + "; ".join(missing))
print("Agrarian weather input mapping verification complete.")
if __name__ == "__main__":
main()
+28
View File
@@ -98,6 +98,7 @@ void AAgrarianGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& O
DOREPLIFETIME(AAgrarianGameState, ObservedTemperatureBlend); DOREPLIFETIME(AAgrarianGameState, ObservedTemperatureBlend);
DOREPLIFETIME(AAgrarianGameState, bHasRegionalObservedTemperature); DOREPLIFETIME(AAgrarianGameState, bHasRegionalObservedTemperature);
DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource); DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource);
DOREPLIFETIME(AAgrarianGameState, ActiveWeatherInputs);
DOREPLIFETIME(AAgrarianGameState, DaysPerAgrarianYear); DOREPLIFETIME(AAgrarianGameState, DaysPerAgrarianYear);
DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId); DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId);
DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude); DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude);
@@ -160,6 +161,33 @@ void AAgrarianGameState::SetRegionalObservedTemperature(float ObservedTemperatur
UpdateAmbientTemperature(); UpdateAmbientTemperature();
} }
void AAgrarianGameState::ApplyMappedWeatherInputs(const FAgrarianMappedWeatherInputs& MappedInputs)
{
if (!HasAuthority())
{
return;
}
ActiveWeatherInputs = MappedInputs;
ActiveWeatherInputs.TemperatureC = FMath::Clamp(ActiveWeatherInputs.TemperatureC, -80.0f, 70.0f);
ActiveWeatherInputs.DailyLowTemperatureC = FMath::Clamp(ActiveWeatherInputs.DailyLowTemperatureC, -80.0f, 70.0f);
ActiveWeatherInputs.DailyHighTemperatureC = FMath::Clamp(ActiveWeatherInputs.DailyHighTemperatureC, -80.0f, 70.0f);
ActiveWeatherInputs.PrecipitationMm = FMath::Max(0.0f, ActiveWeatherInputs.PrecipitationMm);
ActiveWeatherInputs.WindSpeedKmh = FMath::Max(0.0f, ActiveWeatherInputs.WindSpeedKmh);
ActiveWeatherInputs.CloudCoverPercent = FMath::Clamp(ActiveWeatherInputs.CloudCoverPercent, 0.0f, 100.0f);
ActiveWeatherInputs.RelativeHumidityPercent = FMath::Clamp(ActiveWeatherInputs.RelativeHumidityPercent, 0.0f, 100.0f);
ActiveWeatherInputs.PressureMslHpa = FMath::Clamp(ActiveWeatherInputs.PressureMslHpa, 800.0f, 1100.0f);
ActiveWeatherInputs.VisibilityMeters = FMath::Max(0.0f, ActiveWeatherInputs.VisibilityMeters);
ActiveWeatherInputs.bHasProviderData = true;
SetRegionalTemperatureProfile(ActiveWeatherInputs.DailyLowTemperatureC, ActiveWeatherInputs.DailyHighTemperatureC);
SetRegionalObservedTemperature(
ActiveWeatherInputs.TemperatureC,
1.0f,
FString::Printf(TEXT("%s:%s"), *ActiveWeatherInputs.Provider, *ActiveWeatherInputs.ProviderTimestamp));
SetWeather(ActiveWeatherInputs.MappedWeather);
}
float AAgrarianGameState::GetClearSkyTemperatureForHour(float HourOfDay) const float AAgrarianGameState::GetClearSkyTemperatureForHour(float HourOfDay) const
{ {
const float LowTemperature = FMath::Min(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC); const float LowTemperature = FMath::Min(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC);
+6
View File
@@ -52,6 +52,9 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
FString RegionalWeatherSource = TEXT("deterministic_tile_curve"); FString RegionalWeatherSource = TEXT("deterministic_tile_curve");
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Weather")
FAgrarianMappedWeatherInputs ActiveWeatherInputs;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Tile Solar") UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Tile Solar")
FName ActiveSolarTileId = TEXT("gz_us_ca_pacifica_utm10n_e544_n4160"); FName ActiveSolarTileId = TEXT("gz_us_ca_pacifica_utm10n_e544_n4160");
@@ -103,6 +106,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature") UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature")
void SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource); void SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource);
UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Weather")
void ApplyMappedWeatherInputs(const FAgrarianMappedWeatherInputs& MappedInputs);
UFUNCTION(BlueprintPure, Category = "Agrarian|World|Temperature") UFUNCTION(BlueprintPure, Category = "Agrarian|World|Temperature")
float GetClearSkyTemperatureForHour(float HourOfDay) const; float GetClearSkyTemperatureForHour(float HourOfDay) const;
+48
View File
@@ -14,6 +14,54 @@ enum class EAgrarianWeatherType : uint8
Storm UMETA(DisplayName = "Storm") Storm UMETA(DisplayName = "Storm")
}; };
USTRUCT(BlueprintType)
struct FAgrarianMappedWeatherInputs
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float TemperatureC = 12.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float DailyLowTemperatureC = 9.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float DailyHighTemperatureC = 18.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float PrecipitationMm = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float WindSpeedKmh = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float CloudCoverPercent = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float RelativeHumidityPercent = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float PressureMslHpa = 1013.25f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float VisibilityMeters = 10000.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
int32 ProviderWeatherCode = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
EAgrarianWeatherType MappedWeather = EAgrarianWeatherType::Clear;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
FString Provider = TEXT("deterministic");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
FString ProviderTimestamp;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
bool bHasProviderData = false;
};
UENUM(BlueprintType) UENUM(BlueprintType)
enum class EAgrarianSeason : uint8 enum class EAgrarianSeason : uint8
{ {
@@ -111,15 +111,30 @@ bool UAgrarianWeatherProviderSubsystem::ApplySnapshotToGameState(const FAgrarian
return false; return false;
} }
GameState->SetRegionalTemperatureProfile(Snapshot.DailyLowTemperatureC, Snapshot.DailyHighTemperatureC); GameState->ApplyMappedWeatherInputs(MapSnapshotToAgrarianWeatherInputs(Snapshot));
GameState->SetRegionalObservedTemperature(
Snapshot.CurrentTemperatureC,
1.0f,
FString::Printf(TEXT("%s:%s"), *Snapshot.Provider, *Snapshot.ProviderTimestamp));
GameState->SetWeather(Snapshot.MappedWeather);
return true; return true;
} }
FAgrarianMappedWeatherInputs UAgrarianWeatherProviderSubsystem::MapSnapshotToAgrarianWeatherInputs(const FAgrarianWeatherProviderSnapshot& Snapshot) const
{
FAgrarianMappedWeatherInputs MappedInputs;
MappedInputs.TemperatureC = Snapshot.CurrentTemperatureC;
MappedInputs.DailyLowTemperatureC = Snapshot.DailyLowTemperatureC;
MappedInputs.DailyHighTemperatureC = Snapshot.DailyHighTemperatureC;
MappedInputs.PrecipitationMm = Snapshot.PrecipitationMm;
MappedInputs.WindSpeedKmh = Snapshot.WindSpeedKmh;
MappedInputs.CloudCoverPercent = Snapshot.CloudCoverPercent;
MappedInputs.RelativeHumidityPercent = Snapshot.RelativeHumidityPercent;
MappedInputs.PressureMslHpa = Snapshot.PressureMslHpa > 0.0f ? Snapshot.PressureMslHpa : 1013.25f;
MappedInputs.VisibilityMeters = Snapshot.VisibilityMeters;
MappedInputs.ProviderWeatherCode = Snapshot.WeatherCode;
MappedInputs.MappedWeather = Snapshot.MappedWeather;
MappedInputs.Provider = Snapshot.Provider;
MappedInputs.ProviderTimestamp = Snapshot.ProviderTimestamp;
MappedInputs.bHasProviderData = Snapshot.bIsValid;
return MappedInputs;
}
bool UAgrarianWeatherProviderSubsystem::TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState) bool UAgrarianWeatherProviderSubsystem::TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState)
{ {
if (!GameState || !GameState->HasAuthority()) if (!GameState || !GameState->HasAuthority())
@@ -331,6 +346,7 @@ bool UAgrarianWeatherProviderSubsystem::ParseOpenMeteoForecast(const FString& Re
OutSnapshot.CloudCoverPercent = static_cast<float>(CloudCover); OutSnapshot.CloudCoverPercent = static_cast<float>(CloudCover);
OutSnapshot.RelativeHumidityPercent = static_cast<float>(RelativeHumidity); OutSnapshot.RelativeHumidityPercent = static_cast<float>(RelativeHumidity);
OutSnapshot.PressureMslHpa = static_cast<float>(Pressure); OutSnapshot.PressureMslHpa = static_cast<float>(Pressure);
OutSnapshot.VisibilityMeters = FMath::Clamp(10000.0f - (OutSnapshot.CloudCoverPercent * 35.0f) - (OutSnapshot.PrecipitationMm * 250.0f), 250.0f, 10000.0f);
OutSnapshot.WeatherCode = FMath::RoundToInt(WeatherCode); OutSnapshot.WeatherCode = FMath::RoundToInt(WeatherCode);
OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh); OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh);
OutSnapshot.bIsValid = true; OutSnapshot.bIsValid = true;
@@ -378,8 +394,16 @@ bool UAgrarianWeatherProviderSubsystem::ParseNoaaNwsGridData(const FString& Resp
double PrecipitationPercent = 0.0; double PrecipitationPercent = 0.0;
double WindSpeedKmh = 0.0; double WindSpeedKmh = 0.0;
double RelativeHumidity = 0.0;
double SkyCover = 0.0;
double Pressure = 0.0;
double Visibility = 0.0;
ReadFirstGridValue(*PropertiesObject, TEXT("probabilityOfPrecipitation"), PrecipitationPercent); ReadFirstGridValue(*PropertiesObject, TEXT("probabilityOfPrecipitation"), PrecipitationPercent);
ReadFirstGridValue(*PropertiesObject, TEXT("windSpeed"), WindSpeedKmh); ReadFirstGridValue(*PropertiesObject, TEXT("windSpeed"), WindSpeedKmh);
ReadFirstGridValue(*PropertiesObject, TEXT("relativeHumidity"), RelativeHumidity);
ReadFirstGridValue(*PropertiesObject, TEXT("skyCover"), SkyCover);
ReadFirstGridValue(*PropertiesObject, TEXT("pressure"), Pressure);
ReadFirstGridValue(*PropertiesObject, TEXT("visibility"), Visibility);
OutSnapshot.TileId = TileId; OutSnapshot.TileId = TileId;
OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f);
@@ -391,6 +415,10 @@ bool UAgrarianWeatherProviderSubsystem::ParseNoaaNwsGridData(const FString& Resp
OutSnapshot.DailyHighTemperatureC = static_cast<float>(TemperatureC + 4.0); OutSnapshot.DailyHighTemperatureC = static_cast<float>(TemperatureC + 4.0);
OutSnapshot.PrecipitationMm = PrecipitationPercent > 30.0 ? 0.1f : 0.0f; OutSnapshot.PrecipitationMm = PrecipitationPercent > 30.0 ? 0.1f : 0.0f;
OutSnapshot.WindSpeedKmh = static_cast<float>(WindSpeedKmh); OutSnapshot.WindSpeedKmh = static_cast<float>(WindSpeedKmh);
OutSnapshot.CloudCoverPercent = static_cast<float>(SkyCover);
OutSnapshot.RelativeHumidityPercent = static_cast<float>(RelativeHumidity);
OutSnapshot.PressureMslHpa = Pressure > 0.0 ? static_cast<float>(Pressure / 100.0) : 1013.25f;
OutSnapshot.VisibilityMeters = Visibility > 0.0 ? static_cast<float>(Visibility) : FMath::Clamp(10000.0f - (OutSnapshot.CloudCoverPercent * 35.0f), 250.0f, 10000.0f);
OutSnapshot.WeatherCode = PrecipitationPercent > 30.0 ? 61 : 0; OutSnapshot.WeatherCode = PrecipitationPercent > 30.0 ? 61 : 0;
OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh); OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh);
OutSnapshot.bIsValid = true; OutSnapshot.bIsValid = true;
@@ -56,6 +56,9 @@ struct FAgrarianWeatherProviderSnapshot
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float PressureMslHpa = 0.0f; float PressureMslHpa = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
float VisibilityMeters = 10000.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather")
int32 WeatherCode = 0; int32 WeatherCode = 0;
@@ -142,6 +145,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather")
bool ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const; bool ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const;
UFUNCTION(BlueprintPure, Category = "Agrarian|Weather")
FAgrarianMappedWeatherInputs MapSnapshotToAgrarianWeatherInputs(const FAgrarianWeatherProviderSnapshot& Snapshot) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather") UFUNCTION(BlueprintCallable, Category = "Agrarian|Weather")
bool TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState); bool TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState);