diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2d185c3..d2a63aa 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4370,10 +4370,18 @@ public sealed class GameWindow : IDisposable new AcDream.Core.World.SkyStateProvider( grp.SkyTimes.Select(s => s.Keyframe).ToList())); + // Phase 3e: drive the atmospheric weather (rain/snow emitters, + // fog-override categories, lightning strobe) from the retail + // DayGroup name. Stops the legacy WeatherSystem.RollKind hash + // from spawning rain particles on a "Sunny" day (user-observed + // rain regression 2026-04-23 after the retail picker landed on + // DayGroup[6] "Sunny" but the internal hash picked Rain). + Weather.SetKindFromDayGroupName(grp.Name); + Console.WriteLine( $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + - $"{grp.SkyTimes.Count} keyframes)"); + $"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})"); } } diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 15fc543..5f421fd 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -125,6 +125,12 @@ public sealed class WeatherSystem private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" + // Phase 3e — when GameWindow (via RefreshSkyForCurrentDay) pushes + // the active retail DayGroup name through SetKindFromDayGroupName, + // the internal RollKind hash becomes unused. This flag stops Tick's + // auto-roll so external control can't fight the internal one. + private bool _externallyDriven; + private readonly Random _strikeJitter; public WeatherSystem(Random? rng = null) @@ -154,6 +160,46 @@ public sealed class WeatherSystem _rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll } + /// + /// Drive the weather kind from the active retail DayGroup name + /// (see SkyDesc::PickCurrentDayGroup port at + /// LoadedSkyDesc.SelectDayGroupIndex). Retail has ONE source + /// of truth for weather — the DayGroup roll — so this replaces the + /// internal hash once the real DayGroup picker + /// is live. Cases are loose substring matches (Dereth day groups use + /// names like "Sunny", "Clear", "Cloudy", "Rainy", "Stormy", "Snowy" + /// per the region dat dump 2026-04-23). + /// + /// + /// Once called at least once, the internal auto-roll in + /// is DISABLED for the rest of the session — + /// control is now external. Tests that drive + /// directly without calling this method remain on the legacy hash + /// roll unchanged. + /// + /// + public void SetKindFromDayGroupName(string? dayGroupName) + { + _externallyDriven = true; + WeatherKind mapped = MapDayGroupNameToKind(dayGroupName); + if (mapped != _kind) BeginTransition(mapped); + } + + private static WeatherKind MapDayGroupNameToKind(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; + string lc = name.ToLowerInvariant(); + // Order matters — "thunderstorm" contains "storm", match first. + if (lc.Contains("storm")) return WeatherKind.Storm; + if (lc.Contains("snow")) return WeatherKind.Snow; + if (lc.Contains("rain")) return WeatherKind.Rain; + if (lc.Contains("cloud") + || lc.Contains("overcast") + || lc.Contains("dark") + || lc.Contains("fog")) return WeatherKind.Overcast; + return WeatherKind.Clear; // "Sunny", "Clear", anything else + } + /// /// Advance the state machine. Call once per frame from the render /// loop. is the in-game day (derived @@ -167,8 +213,13 @@ public sealed class WeatherSystem if (_transitionT < 1f) _transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds); - // Day changed → re-roll. Skip the sentinel (forced). - if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue) + // Day changed → re-roll. Skip the sentinel (forced). Also skip + // when weather is externally driven by the retail DayGroup name + // (Phase 3e) — the internal RollKind is a fallback only for + // tests / offline code paths. + if (!_externallyDriven + && dayIndex != _rolledDayIndex + && _rolledDayIndex != int.MaxValue) { _rolledDayIndex = dayIndex; var newKind = RollKind(dayIndex); diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index e2c8d48..9623f4f 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -99,4 +99,47 @@ public sealed class WeatherSystemTests Assert.Equal(kf.FogStart, snap.FogStart, precision: 2); Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2); } + + [Theory] + [InlineData("Sunny", WeatherKind.Clear)] + [InlineData("SUNNY", WeatherKind.Clear)] + [InlineData("Clear", WeatherKind.Clear)] + [InlineData("Cloudy", WeatherKind.Overcast)] + [InlineData("Overcast", WeatherKind.Overcast)] + [InlineData("Dark skies", WeatherKind.Overcast)] + [InlineData("Fog", WeatherKind.Overcast)] + [InlineData("Rainy", WeatherKind.Rain)] + [InlineData("heavy rain", WeatherKind.Rain)] + [InlineData("Snowy", WeatherKind.Snow)] + [InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default + [InlineData("Stormy", WeatherKind.Storm)] + [InlineData("Thunderstorm", WeatherKind.Storm)] // "storm" wins over no match + [InlineData("", WeatherKind.Clear)] + [InlineData(null, WeatherKind.Clear)] + public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected) + { + var sys = new WeatherSystem(); + sys.SetKindFromDayGroupName(name); + sys.Tick(0, 0, 100f); // finalize transition + Assert.Equal(expected, sys.Kind); + } + + [Fact] + public void SetKindFromDayGroupName_DisablesInternalRoll() + { + // Once driven externally, advancing dayIndex must NOT re-roll + // to a different kind via the internal RollKind hash. + var sys = new WeatherSystem(); + sys.SetKindFromDayGroupName("Sunny"); + sys.Tick(0, 0, 100f); + + var clearKind = sys.Kind; + Assert.Equal(WeatherKind.Clear, clearKind); + + for (int d = 1; d < 50; d++) + { + sys.Tick(0, d, 100f); + Assert.Equal(clearKind, sys.Kind); // stays put — no auto-roll + } + } }