diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 7515de0..e860c2e 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -417,7 +417,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Set default server time scale to `4 real hours = 1 in-game day`. - [x] Add real local time-zone and sunrise/sunset lookup for Ground Zero by latitude/longitude. Added tile-aware solar metadata to `AAgrarianGameState`, Ground Zero Pacifica defaults, local sunrise/sunset/solar-noon/day-length approximation, replicated solar state, `IsNight` based on active tile solar bounds, and a repeatable registry-driven solar metadata generator that skips placeholder/unknown tiles. - [x] Add Agrarian calendar conversion helpers for days, seasons, crop cycles, livestock maturity, spoilage, and long-running tasks. Added replicated year/day and absolute-day helpers, season/day-of-season lookup, `4 real hours = 1 in-game day` conversion helpers, long-task progress calculation, active tile growing-zone profile, crop maturity fit checks against frost-free/growing-season windows, and a repeatable growing-zone metadata generator that skips placeholder/unknown tiles. -- [ ] Add temperature curve by time of day. +- [x] Add temperature curve by time of day. Added replicated regional daily low/high temperatures, a sunrise-to-afternoon warming curve, overnight cooling, observed regional temperature blending for future real-weather adapters, weather modifiers, and deterministic fallback behavior when no provider data is available. - [x] Add simple weather states. - [x] Add clear weather. - [x] Add rain. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index dfefbe8..0e448f6 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -137,6 +137,16 @@ conservative Pacifica coastal profile; later regional expansion should replace or enrich these overrides with authoritative zone, climate, and temperature datasets. +Temperature is authoritative on `AAgrarianGameState`. The MVP curve uses the +active tile's sunrise and solar noon to place the daily low near sunrise and the +daily high after solar noon, then applies weather modifiers for rain, cold wind, +and storms. Regional daily low/high values provide the deterministic fallback. +When a server-side weather adapter is available, it should set observed regional +temperature and blend weight through the game-state hook rather than allowing +clients to call public weather APIs directly. This keeps real-world temperature +and weather tied to the represented map tile while preserving a deterministic +fallback if an external provider is unavailable. + ## Terrain And Tile Delivery ### MVP Tile diff --git a/Scripts/verify_temperature_curve.py b/Scripts/verify_temperature_curve.py new file mode 100644 index 0000000..eb4d39b --- /dev/null +++ b/Scripts/verify_temperature_curve.py @@ -0,0 +1,58 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GAME_STATE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.h" +GAME_STATE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.cpp" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + + +EXPECTED = { + GAME_STATE_H: [ + "RegionalDailyLowTemperatureC", + "RegionalDailyHighTemperatureC", + "RegionalObservedTemperatureC", + "ObservedTemperatureBlend", + "bHasRegionalObservedTemperature", + "SetRegionalTemperatureProfile", + "SetRegionalObservedTemperature", + "GetClearSkyTemperatureForHour", + ], + GAME_STATE_CPP: [ + "GetWrappedHourDelta", + "DOREPLIFETIME(AAgrarianGameState, RegionalDailyLowTemperatureC);", + "DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource);", + "void AAgrarianGameState::SetRegionalTemperatureProfile", + "void AAgrarianGameState::SetRegionalObservedTemperature", + "float AAgrarianGameState::GetClearSkyTemperatureForHour", + "const float LowHour = bHasActiveTileSolarData ? SunriseHourLocal : 6.0f;", + "const float HighHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f;", + "AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f);", + ], + TDD: [ + "Temperature is authoritative on `AAgrarianGameState`", + "server-side weather adapter", + "deterministic fallback", + ], + ROADMAP: [ + "[x] Add temperature curve by time of day.", + "observed regional temperature blending", + ], +} + + +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("Temperature curve verification failed: " + "; ".join(missing)) + print("Agrarian temperature curve verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianGameState.cpp b/Source/AgrarianGame/AgrarianGameState.cpp index 1b905da..f5c86d9 100644 --- a/Source/AgrarianGame/AgrarianGameState.cpp +++ b/Source/AgrarianGame/AgrarianGameState.cpp @@ -25,6 +25,16 @@ bool IsDayInRange(int32 DayOfYear, int32 StartDay, int32 EndDay, int32 DaysPerYe return Day >= Start || Day <= End; } + +float GetWrappedHourDelta(float FromHour, float ToHour) +{ + float Delta = FMath::Fmod(ToHour - FromHour, 24.0f); + if (Delta < 0.0f) + { + Delta += 24.0f; + } + return Delta; +} } AAgrarianGameState::AAgrarianGameState() @@ -82,6 +92,12 @@ void AAgrarianGameState::GetLifetimeReplicatedProps(TArray& O DOREPLIFETIME(AAgrarianGameState, WorldHours); DOREPLIFETIME(AAgrarianGameState, Weather); DOREPLIFETIME(AAgrarianGameState, AmbientTemperatureC); + DOREPLIFETIME(AAgrarianGameState, RegionalDailyLowTemperatureC); + DOREPLIFETIME(AAgrarianGameState, RegionalDailyHighTemperatureC); + DOREPLIFETIME(AAgrarianGameState, RegionalObservedTemperatureC); + DOREPLIFETIME(AAgrarianGameState, ObservedTemperatureBlend); + DOREPLIFETIME(AAgrarianGameState, bHasRegionalObservedTemperature); + DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource); DOREPLIFETIME(AAgrarianGameState, DaysPerAgrarianYear); DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId); DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude); @@ -118,6 +134,56 @@ void AAgrarianGameState::SetWeather(EAgrarianWeatherType NewWeather) } } +void AAgrarianGameState::SetRegionalTemperatureProfile(float DailyLowTemperatureC, float DailyHighTemperatureC) +{ + if (!HasAuthority()) + { + return; + } + + RegionalDailyLowTemperatureC = FMath::Clamp(FMath::Min(DailyLowTemperatureC, DailyHighTemperatureC), -80.0f, 70.0f); + RegionalDailyHighTemperatureC = FMath::Clamp(FMath::Max(DailyLowTemperatureC, DailyHighTemperatureC), -80.0f, 70.0f); + UpdateAmbientTemperature(); +} + +void AAgrarianGameState::SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource) +{ + if (!HasAuthority()) + { + return; + } + + RegionalObservedTemperatureC = FMath::Clamp(ObservedTemperatureC, -80.0f, 70.0f); + ObservedTemperatureBlend = FMath::Clamp(BlendWeight, 0.0f, 1.0f); + bHasRegionalObservedTemperature = ObservedTemperatureBlend > 0.0f; + RegionalWeatherSource = WeatherSource.IsEmpty() ? TEXT("server_weather_adapter") : WeatherSource; + UpdateAmbientTemperature(); +} + +float AAgrarianGameState::GetClearSkyTemperatureForHour(float HourOfDay) const +{ + const float LowTemperature = FMath::Min(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC); + const float HighTemperature = FMath::Max(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC); + const float LowHour = bHasActiveTileSolarData ? SunriseHourLocal : 6.0f; + const float HighHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f; + const float NormalizedHour = FMath::Fmod(HourOfDay, 24.0f) < 0.0f + ? FMath::Fmod(HourOfDay, 24.0f) + 24.0f + : FMath::Fmod(HourOfDay, 24.0f); + + const float RisingDuration = FMath::Max(0.1f, GetWrappedHourDelta(LowHour, HighHour)); + const float HoursSinceLow = GetWrappedHourDelta(LowHour, NormalizedHour); + if (HoursSinceLow <= RisingDuration) + { + const float Alpha = 0.5f - (0.5f * FMath::Cos(PI * (HoursSinceLow / RisingDuration))); + return FMath::Lerp(LowTemperature, HighTemperature, Alpha); + } + + const float CoolingDuration = FMath::Max(0.1f, 24.0f - RisingDuration); + const float HoursSinceHigh = GetWrappedHourDelta(HighHour, NormalizedHour); + const float Alpha = 0.5f - (0.5f * FMath::Cos(PI * (HoursSinceHigh / CoolingDuration))); + return FMath::Lerp(HighTemperature, LowTemperature, Alpha); +} + bool AAgrarianGameState::ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours) { if (!HasAuthority() || TileId == NAME_None) @@ -380,14 +446,20 @@ void AAgrarianGameState::UpdateSolarTimes() void AAgrarianGameState::UpdateAmbientTemperature() { - const float WarmestHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f; - const float DayWarmth = FMath::Sin(((WorldHours - WarmestHour) / 24.0f) * 2.0f * PI + (PI * 0.5f)) * 8.0f; + const float ClearSkyTemperatureC = GetClearSkyTemperatureForHour(WorldHours); + const float DailyMeanTemperatureC = (RegionalDailyLowTemperatureC + RegionalDailyHighTemperatureC) * 0.5f; + const float ObservedAnchoredTemperatureC = bHasRegionalObservedTemperature + ? RegionalObservedTemperatureC + (ClearSkyTemperatureC - DailyMeanTemperatureC) + : ClearSkyTemperatureC; + const float BaseTemperatureC = bHasRegionalObservedTemperature + ? FMath::Lerp(ClearSkyTemperatureC, ObservedAnchoredTemperatureC, ObservedTemperatureBlend) + : ClearSkyTemperatureC; float WeatherModifier = 0.0f; switch (Weather) { case EAgrarianWeatherType::Rain: - WeatherModifier = -3.0f; + WeatherModifier = -2.0f; break; case EAgrarianWeatherType::ColdWind: WeatherModifier = -8.0f; @@ -400,5 +472,5 @@ void AAgrarianGameState::UpdateAmbientTemperature() break; } - AmbientTemperatureC = 10.0f + DayWarmth + WeatherModifier; + AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f); } diff --git a/Source/AgrarianGame/AgrarianGameState.h b/Source/AgrarianGame/AgrarianGameState.h index 83ace19..7b6c1de 100644 --- a/Source/AgrarianGame/AgrarianGameState.h +++ b/Source/AgrarianGame/AgrarianGameState.h @@ -34,6 +34,24 @@ public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World") float AmbientTemperatureC = 12.0f; + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") + float RegionalDailyLowTemperatureC = 9.0f; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") + float RegionalDailyHighTemperatureC = 18.0f; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") + float RegionalObservedTemperatureC = 12.0f; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ObservedTemperatureBlend = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") + bool bHasRegionalObservedTemperature = false; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature") + FString RegionalWeatherSource = TEXT("deterministic_tile_curve"); + UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Tile Solar") FName ActiveSolarTileId = TEXT("gz_us_ca_pacifica_utm10n_e544_n4160"); @@ -79,6 +97,15 @@ public: UFUNCTION(BlueprintCallable, Category = "Agrarian|World") void SetWeather(EAgrarianWeatherType NewWeather); + UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature") + void SetRegionalTemperatureProfile(float DailyLowTemperatureC, float DailyHighTemperatureC); + + UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature") + void SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource); + + UFUNCTION(BlueprintPure, Category = "Agrarian|World|Temperature") + float GetClearSkyTemperatureForHour(float HourOfDay) const; + UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Tile Solar") bool ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours);