// Copyright Pacificao. All Rights Reserved. #include "AgrarianGameState.h" #include "Net/UnrealNetwork.h" namespace { int32 NormalizeCalendarDay(int32 DayOfYear, int32 DaysPerYear) { const int32 SafeDaysPerYear = FMath::Max(1, DaysPerYear); const int32 ZeroBasedDay = ((DayOfYear - 1) % SafeDaysPerYear + SafeDaysPerYear) % SafeDaysPerYear; return ZeroBasedDay + 1; } bool IsDayInRange(int32 DayOfYear, int32 StartDay, int32 EndDay, int32 DaysPerYear) { const int32 Day = NormalizeCalendarDay(DayOfYear, DaysPerYear); const int32 Start = NormalizeCalendarDay(StartDay, DaysPerYear); const int32 End = NormalizeCalendarDay(EndDay, DaysPerYear); if (Start <= End) { return Day >= Start && Day <= End; } 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() { PrimaryActorTick.bCanEverTick = true; bReplicates = true; ActiveGrowingSeason.TileId = ActiveSolarTileId; ActiveGrowingSeason.GrowingZoneLabel = TEXT("USDA 10a"); ActiveGrowingSeason.GrowingSeasonStartDay = 46; ActiveGrowingSeason.GrowingSeasonEndDay = 350; ActiveGrowingSeason.FrostFreeDays = 305; ActiveGrowingSeason.MinAverageGrowingTempC = 7.0f; ActiveGrowingSeason.CropSafetyBufferDays = 14; ActiveGrowingSeason.ClimateProfile = TEXT("coastal_mediterranean_mild"); } void AAgrarianGameState::BeginPlay() { Super::BeginPlay(); if (HasAuthority()) { UpdateSolarTimes(); } } void AAgrarianGameState::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (!HasAuthority()) { return; } WorldHours += (DeltaSeconds / 60.0f) * GameHoursPerRealMinute; while (WorldHours >= 24.0f) { WorldHours -= 24.0f; const int32 PreviousDayOfYear = ActiveDayOfYear; ActiveDayOfYear = (ActiveDayOfYear % FMath::Max(1, DaysPerAgrarianYear)) + 1; if (ActiveDayOfYear < PreviousDayOfYear) { ++ActiveYear; } UpdateSolarTimes(); } UpdateAmbientTemperature(); } void AAgrarianGameState::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); 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, ActiveWeatherInputs); DOREPLIFETIME(AAgrarianGameState, DaysPerAgrarianYear); DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId); DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude); DOREPLIFETIME(AAgrarianGameState, ActiveTileLongitude); DOREPLIFETIME(AAgrarianGameState, ActiveTileTimeZoneId); DOREPLIFETIME(AAgrarianGameState, ActiveTileUtcOffsetHours); DOREPLIFETIME(AAgrarianGameState, ActiveDayOfYear); DOREPLIFETIME(AAgrarianGameState, ActiveYear); DOREPLIFETIME(AAgrarianGameState, ActiveGrowingSeason); DOREPLIFETIME(AAgrarianGameState, bHasActiveTileSolarData); DOREPLIFETIME(AAgrarianGameState, SunriseHourLocal); DOREPLIFETIME(AAgrarianGameState, SunsetHourLocal); DOREPLIFETIME(AAgrarianGameState, SolarNoonHourLocal); DOREPLIFETIME(AAgrarianGameState, DayLengthHours); } bool AAgrarianGameState::IsNight() const { if (bHasActiveTileSolarData) { return WorldHours < SunriseHourLocal || WorldHours > SunsetHourLocal; } return WorldHours < 6.0f || WorldHours > 20.0f; } void AAgrarianGameState::SetWeather(EAgrarianWeatherType NewWeather) { if (HasAuthority()) { Weather = NewWeather; UpdateAmbientTemperature(); OnRep_Weather(); } } 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(); } 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 { 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) { return false; } ActiveSolarTileId = TileId; ActiveTileLatitude = FMath::Clamp(Latitude, -90.0f, 90.0f); ActiveTileLongitude = FMath::Clamp(Longitude, -180.0f, 180.0f); ActiveTileTimeZoneId = TimeZoneId; ActiveTileUtcOffsetHours = FMath::Clamp(UtcOffsetHours, -12.0f, 14.0f); ActiveGrowingSeason.TileId = TileId; UpdateSolarTimes(); UpdateAmbientTemperature(); return bHasActiveTileSolarData; } FAgrarianCalendarSnapshot AAgrarianGameState::GetCalendarSnapshot() const { FAgrarianCalendarSnapshot Snapshot; Snapshot.DayOfYear = NormalizeCalendarDay(ActiveDayOfYear, DaysPerAgrarianYear); Snapshot.Year = FMath::Max(1, ActiveYear); Snapshot.AbsoluteDay = GetAbsoluteAgrarianDay(); Snapshot.DayOfSeason = GetDayOfSeason(Snapshot.DayOfYear); Snapshot.Season = GetSeasonForDay(Snapshot.DayOfYear); Snapshot.HourOfDay = FMath::Clamp(WorldHours, 0.0f, 24.0f); Snapshot.bInsideGrowingSeason = IsDayInsideActiveGrowingSeason(Snapshot.DayOfYear); Snapshot.GrowingSeasonDaysRemaining = GetDaysRemainingInActiveGrowingSeason(Snapshot.DayOfYear); return Snapshot; } int32 AAgrarianGameState::GetAbsoluteAgrarianDay() const { const int32 SafeYear = FMath::Max(1, ActiveYear); const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear); return ((SafeYear - 1) * SafeDaysPerYear) + NormalizeCalendarDay(ActiveDayOfYear, SafeDaysPerYear); } EAgrarianSeason AAgrarianGameState::GetSeasonForDay(int32 DayOfYear) const { const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear); const int32 SeasonalDay = ActiveTileLatitude < 0.0f ? NormalizeCalendarDay(DayOfYear + (SafeDaysPerYear / 2), SafeDaysPerYear) : NormalizeCalendarDay(DayOfYear, SafeDaysPerYear); if (SeasonalDay >= 355 || SeasonalDay < 80) { return EAgrarianSeason::Winter; } if (SeasonalDay < 172) { return EAgrarianSeason::Spring; } if (SeasonalDay < 264) { return EAgrarianSeason::Summer; } return EAgrarianSeason::Autumn; } int32 AAgrarianGameState::GetDayOfSeason(int32 DayOfYear) const { const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear); const int32 SeasonalDay = ActiveTileLatitude < 0.0f ? NormalizeCalendarDay(DayOfYear + (SafeDaysPerYear / 2), SafeDaysPerYear) : NormalizeCalendarDay(DayOfYear, SafeDaysPerYear); switch (GetSeasonForDay(DayOfYear)) { case EAgrarianSeason::Spring: return SeasonalDay - 79; case EAgrarianSeason::Summer: return SeasonalDay - 171; case EAgrarianSeason::Autumn: return SeasonalDay - 263; default: return SeasonalDay >= 355 ? SeasonalDay - 354 : SeasonalDay + 12; } } float AAgrarianGameState::ConvertAgrarianDaysToRealHours(float AgrarianDays) const { const float SafeGameHoursPerRealMinute = FMath::Max(0.001f, GameHoursPerRealMinute); return FMath::Max(0.0f, AgrarianDays) * (24.0f / SafeGameHoursPerRealMinute) / 60.0f; } float AAgrarianGameState::ConvertRealHoursToAgrarianDays(float RealHours) const { return (FMath::Max(0.0f, RealHours) * 60.0f * FMath::Max(0.001f, GameHoursPerRealMinute)) / 24.0f; } float AAgrarianGameState::GetLongTaskProgress(int32 StartAbsoluteDay, float StartHourOfDay, float DurationAgrarianDays) const { const float SafeDuration = FMath::Max(0.001f, DurationAgrarianDays); const float CurrentAgrarianDay = static_cast(GetAbsoluteAgrarianDay() - 1) + (WorldHours / 24.0f); const float StartAgrarianDay = static_cast(FMath::Max(1, StartAbsoluteDay) - 1) + (FMath::Clamp(StartHourOfDay, 0.0f, 24.0f) / 24.0f); return FMath::Clamp((CurrentAgrarianDay - StartAgrarianDay) / SafeDuration, 0.0f, 1.0f); } bool AAgrarianGameState::IsDayInsideActiveGrowingSeason(int32 DayOfYear) const { return IsDayInRange( DayOfYear, ActiveGrowingSeason.GrowingSeasonStartDay, ActiveGrowingSeason.GrowingSeasonEndDay, DaysPerAgrarianYear); } int32 AAgrarianGameState::GetDaysRemainingInActiveGrowingSeason(int32 PlantDayOfYear) const { const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear); const int32 Day = NormalizeCalendarDay(PlantDayOfYear, SafeDaysPerYear); const int32 Start = NormalizeCalendarDay(ActiveGrowingSeason.GrowingSeasonStartDay, SafeDaysPerYear); const int32 End = NormalizeCalendarDay(ActiveGrowingSeason.GrowingSeasonEndDay, SafeDaysPerYear); if (Start <= End) { if (Day < Start) { return End - Start + 1; } if (Day > End) { return 0; } return End - Day + 1; } if (Day >= Start) { return (SafeDaysPerYear - Day + 1) + End; } if (Day <= End) { return End - Day + 1; } return 0; } FAgrarianCropSeasonAssessment AAgrarianGameState::AssessCropForActiveGrowingSeason(int32 CropMaturityDays, int32 PlantDayOfYear) const { FAgrarianCropSeasonAssessment Assessment; Assessment.DaysAvailable = GetDaysRemainingInActiveGrowingSeason(PlantDayOfYear); Assessment.SafetyBufferDays = FMath::Max(0, ActiveGrowingSeason.CropSafetyBufferDays); Assessment.DaysRequired = FMath::Max(0, CropMaturityDays) + Assessment.SafetyBufferDays; Assessment.GrowingZoneLabel = ActiveGrowingSeason.GrowingZoneLabel; Assessment.bCanPlantToday = false; if (CropMaturityDays <= 0) { Assessment.Fit = EAgrarianCropSeasonFit::Unknown; Assessment.Reason = FText::FromString(TEXT("Crop maturity days must be greater than zero.")); return Assessment; } if (!IsDayInsideActiveGrowingSeason(PlantDayOfYear)) { Assessment.Fit = EAgrarianCropSeasonFit::OutOfSeason; Assessment.Reason = FText::FromString(TEXT("Planting day is outside the active tile growing season.")); return Assessment; } if (Assessment.DaysRequired > ActiveGrowingSeason.FrostFreeDays) { Assessment.Fit = EAgrarianCropSeasonFit::TooLong; Assessment.Reason = FText::FromString(TEXT("Crop maturity is too long for this tile's frost-free growing window.")); return Assessment; } if (Assessment.DaysRequired > Assessment.DaysAvailable) { Assessment.Fit = EAgrarianCropSeasonFit::Marginal; Assessment.Reason = FText::FromString(TEXT("Crop can grow in this zone, but this planting date is too late for a reliable harvest.")); return Assessment; } Assessment.Fit = EAgrarianCropSeasonFit::FitsSeason; Assessment.bCanPlantToday = true; Assessment.Reason = FText::FromString(TEXT("Crop maturity fits the active tile growing season.")); return Assessment; } void AAgrarianGameState::OnRep_Weather() { UpdateAmbientTemperature(); } void AAgrarianGameState::UpdateSolarTimes() { if (ActiveSolarTileId == NAME_None) { bHasActiveTileSolarData = false; SunriseHourLocal = 6.0f; SunsetHourLocal = 20.0f; SolarNoonHourLocal = 13.0f; DayLengthHours = 14.0f; return; } const float ClampedLatitude = FMath::Clamp(ActiveTileLatitude, -89.8f, 89.8f); const float ClampedLongitude = FMath::Clamp(ActiveTileLongitude, -180.0f, 180.0f); const int32 ClampedDayOfYear = FMath::Clamp(ActiveDayOfYear, 1, 366); // NOAA approximate sunrise/sunset model; good enough for MVP regional light timing. const double Gamma = (2.0 * PI / 365.0) * (static_cast(ClampedDayOfYear) - 1.0); const double EquationOfTimeMinutes = 229.18 * ( 0.000075 + 0.001868 * FMath::Cos(Gamma) - 0.032077 * FMath::Sin(Gamma) - 0.014615 * FMath::Cos(2.0 * Gamma) - 0.040849 * FMath::Sin(2.0 * Gamma)); const double SolarDeclinationRadians = 0.006918 - 0.399912 * FMath::Cos(Gamma) + 0.070257 * FMath::Sin(Gamma) - 0.006758 * FMath::Cos(2.0 * Gamma) + 0.000907 * FMath::Sin(2.0 * Gamma) - 0.002697 * FMath::Cos(3.0 * Gamma) + 0.00148 * FMath::Sin(3.0 * Gamma); const double LatitudeRadians = FMath::DegreesToRadians(static_cast(ClampedLatitude)); const double SolarZenithRadians = FMath::DegreesToRadians(90.833); const double HourAngleArgument = (FMath::Cos(SolarZenithRadians) / (FMath::Cos(LatitudeRadians) * FMath::Cos(SolarDeclinationRadians))) - FMath::Tan(LatitudeRadians) * FMath::Tan(SolarDeclinationRadians); if (HourAngleArgument <= -1.0 || HourAngleArgument >= 1.0) { bHasActiveTileSolarData = true; SolarNoonHourLocal = FMath::Fmod(12.0f + ActiveTileUtcOffsetHours - (ClampedLongitude / 15.0f), 24.0f); if (SolarNoonHourLocal < 0.0f) { SolarNoonHourLocal += 24.0f; } DayLengthHours = HourAngleArgument <= -1.0 ? 24.0f : 0.0f; SunriseHourLocal = DayLengthHours >= 24.0f ? 0.0f : SolarNoonHourLocal; SunsetHourLocal = DayLengthHours >= 24.0f ? 24.0f : SolarNoonHourLocal; return; } const double HourAngleDegrees = FMath::RadiansToDegrees(FMath::Acos(HourAngleArgument)); const double SolarNoonMinutes = 720.0 - (4.0 * ClampedLongitude) - EquationOfTimeMinutes + (ActiveTileUtcOffsetHours * 60.0); const double SunriseMinutes = SolarNoonMinutes - (HourAngleDegrees * 4.0); const double SunsetMinutes = SolarNoonMinutes + (HourAngleDegrees * 4.0); auto NormalizeHour = [](double Minutes) { double Hours = FMath::Fmod(Minutes / 60.0, 24.0); if (Hours < 0.0) { Hours += 24.0; } return static_cast(Hours); }; bHasActiveTileSolarData = true; SunriseHourLocal = NormalizeHour(SunriseMinutes); SunsetHourLocal = NormalizeHour(SunsetMinutes); SolarNoonHourLocal = NormalizeHour(SolarNoonMinutes); DayLengthHours = static_cast((SunsetMinutes - SunriseMinutes) / 60.0); } void AAgrarianGameState::UpdateAmbientTemperature() { 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 = -2.0f; break; case EAgrarianWeatherType::ColdWind: WeatherModifier = -8.0f; break; case EAgrarianWeatherType::Storm: WeatherModifier = -5.0f; break; default: WeatherModifier = 0.0f; break; } AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f); }