diff --git a/docs/design/inventory.md b/docs/design/inventory.md index bf69472..79fb54a 100644 --- a/docs/design/inventory.md +++ b/docs/design/inventory.md @@ -67,7 +67,9 @@ ## Климат и погода Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки** -(нет / дождь / снег). Его видно у часов, и от него одеваются. +(нет / дождь / снег). Его видно у часов, и от него одеваются. Дождь и снег ещё добавляют минуты +к запасу на дорогу (`BehaviorDef.commuteRainMinutes` / `commuteSnowMinutes`): план дня получает +уже число, симуляция считает его с улицы. Ходьбу по коридору это не режет. Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму, не трогая имена. @@ -418,7 +420,7 @@ | Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата | | Create | Пикер страны вместо набора имён; язык — если у страны их несколько | | Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется | -| Погода | Температура и осадки; у часов; в кадре часов | +| Погода | Температура и осадки; у часов; одежда; минуты на дорогу | | В здании | Теплее улицы всегда; комфорт — если нанят техник | | Техник | Комната + должность; холод летом и тепло зимой | | Нужда | `Warmth`; черты теплолюбивый / холодолюбивый | diff --git a/docs/phases/53-weather-commute.md b/docs/phases/53-weather-commute.md index 5e35432..ecfc066 100644 --- a/docs/phases/53-weather-commute.md +++ b/docs/phases/53-weather-commute.md @@ -11,22 +11,22 @@ ## Задачи -- [ ] `BehaviorDef.commuteRainMinutes` и `commuteSnowMinutes`; ваниль 3 и 6 -- [ ] Валидатор: не отрицательные -- [ ] `DayPlans.Build` принимает уже посчитанные лишние минуты; `HSchool.Ai` не знает осадков -- [ ] Симуляция при сборке плана дня подставляет минуты из текущей улицы -- [ ] Лог на каждого не пишем — опоздание видно местом -- [ ] В [`inventory.md`](../design/inventory.md) поправка: погода влияет на дорогу, не только +- [x] `BehaviorDef.commuteRainMinutes` и `commuteSnowMinutes`; ваниль 3 и 6 +- [x] Валидатор: не отрицательные +- [x] `DayPlans.Build` принимает уже посчитанные лишние минуты; `HSchool.Ai` не знает осадков +- [x] Симуляция при сборке плана дня подставляет минуты из текущей улицы +- [x] Лог на каждого не пишем — опоздание видно местом +- [x] В [`inventory.md`](../design/inventory.md) поправка: погода влияет на дорогу, не только на переодевание ## Тесты, без которых фаза не закрыта -- [ ] Снег → `AppearAt` раньше на `commuteSnowMinutes` относительно ясного того же дня +- [x] Снег → `AppearAt` раньше на `commuteSnowMinutes` относительно ясного того же дня (тот же человек, сид, дата без осадков) -- [ ] Дождь → на `commuteRainMinutes` -- [ ] Ясно → минуты как сейчас, только черты и бросок -- [ ] Пак без полей — 0 лишних минут, каталог не падает -- [ ] Тот же сид и снег → тот же `AppearAt` +- [x] Дождь → на `commuteRainMinutes` +- [x] Ясно → минуты как сейчас, только черты и бросок +- [x] Пак без полей — 0 лишних минут, каталог не падает +- [x] Тот же сид и снег → тот же `AppearAt` ## Критерий готовности diff --git a/src/HSchool.Ai/DayPlan.cs b/src/HSchool.Ai/DayPlan.cs index 583db45..c62007b 100644 --- a/src/HSchool.Ai/DayPlan.cs +++ b/src/HSchool.Ai/DayPlan.cs @@ -10,6 +10,10 @@ public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime public bool Comes => AppearAt is not null; } +/// +/// Day plans. Rain and snow arrive as already-counted extra minutes — this project does not +/// know precipitation. +/// public static class DayPlans { public static DayPlan Build( @@ -20,7 +24,8 @@ public static class DayPlans Timetable? timetable, DateTime time, int weekDays, - int schoolSeed) + int schoolSeed, + int extraCommuteMinutes = 0) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(walks); @@ -48,7 +53,7 @@ public static class DayPlans travel = 0f; } - var slack = SlackMinutes(catalog, person, schoolSeed, day); + var slack = SlackMinutes(catalog, person, schoolSeed, day) + Math.Max(0, extraCommuteMinutes); var appear = DateTime.SpecifyKind(utc.Date.Add(firstStart.ToTimeSpan()).AddMinutes(-(travel + slack)), DateTimeKind.Utc); var walkHome = DateTime.SpecifyKind(utc.Date.Add(lastEnd.ToTimeSpan()), DateTimeKind.Utc); return new DayPlan(day, appear, walkHome, firstRoom); diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 42e9407..a46c218 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -522,6 +522,12 @@ internal static class PeopleDefValidator $"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max."); } + if (behavior.CommuteRainMinutes < 0 || behavior.CommuteSnowMinutes < 0) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' commute rain and snow minutes cannot be negative."); + } + if (behavior.DutyLessonWeight < 0f || behavior.DutyTravelWeight < 0f || behavior.NeedWeightAtZero < 0f diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index f4079b3..9bb03ca 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -285,6 +285,15 @@ public sealed class BehaviorDef : Def public int CommuteSlackMax { get; init; } + /// + /// Extra minutes on a rainy morning. Added to slack when the street is wet. A pack without + /// the field adds nothing so the catalog still loads. + /// + public int CommuteRainMinutes { get; init; } + + /// Same as when the street is snow. + public int CommuteSnowMinutes { get; init; } + /// A new goal must beat the current one by this much before the person switches. public float SwitchMargin { get; init; } diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 512f54e..314599a 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -10,6 +10,9 @@ // the same seed still arrives at the same minute. "commuteSlackMin": 0, "commuteSlackMax": 6, + // Extra minutes on the road when the street is wet. Snow is slower than rain. + "commuteRainMinutes": 3, + "commuteSnowMinutes": 6, "switchMargin": 0.15, // Lesson or posted work. Beats leisure (3) and a need that only just crossed // the threshold; a toilet at zero (20) still leaves class. diff --git a/src/HSchool.Simulation/OutdoorWeather.cs b/src/HSchool.Simulation/OutdoorWeather.cs index 9ab5215..4bf146b 100644 --- a/src/HSchool.Simulation/OutdoorWeather.cs +++ b/src/HSchool.Simulation/OutdoorWeather.cs @@ -1,3 +1,5 @@ +using HSchool.Content; + namespace HSchool.Simulation; /// Street precipitation. Below 0 °C the same roll is snow, above it is rain. @@ -17,4 +19,23 @@ public readonly record struct OutdoorWeather(float TemperatureC, Precipitation P Math.Round(TemperatureC * 10d, MidpointRounding.AwayFromZero), short.MinValue, short.MaxValue); + + /// + /// Extra commute minutes for today's street. Ai receives this number; it does not see + /// precipitation. A pack without the fields adds nothing. + /// + public int ExtraCommuteMinutes(BehaviorDef? rules) + { + if (rules is null) + { + return 0; + } + + return Precipitation switch + { + Precipitation.Rain => rules.CommuteRainMinutes, + Precipitation.Snow => rules.CommuteSnowMinutes, + _ => 0, + }; + } } diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index 626b2af..694a754 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -217,7 +217,8 @@ internal static class PresenceSystem school.Timetable, school.Clock.Time, school.SchoolWeekDays, - school.PeopleSeed); + school.PeopleSeed, + school.Weather.ExtraCommuteMinutes(school.Catalog!.BehaviorRules)); } school.DecisionQueue.Clear(); diff --git a/tests/HSchool.Ai.Tests/WalkingTests.cs b/tests/HSchool.Ai.Tests/WalkingTests.cs index 0cff74a..92447e8 100644 --- a/tests/HSchool.Ai.Tests/WalkingTests.cs +++ b/tests/HSchool.Ai.Tests/WalkingTests.cs @@ -133,6 +133,61 @@ public class WalkingTests Assert.True(first.AppearAt < DateTime.SpecifyKind(TuesdayLesson.Date.Add(new TimeSpan(8, 30, 0)), DateTimeKind.Utc)); } + [Fact] + public void SnowMinutes_AppearEarlierThanClear() + { + var (clear, snow, extra) = PlansWithExtra(catalog => catalog.BehaviorRules!.CommuteSnowMinutes); + Assert.Equal(6, extra); + Assert.Equal(clear.AppearAt!.Value.AddMinutes(-extra), snow.AppearAt); + } + + [Fact] + public void RainMinutes_AppearEarlierThanClear() + { + var (clear, rain, extra) = PlansWithExtra(catalog => catalog.BehaviorRules!.CommuteRainMinutes); + Assert.Equal(3, extra); + Assert.Equal(clear.AppearAt!.Value.AddMinutes(-extra), rain.AppearAt); + } + + [Fact] + public void Clear_MatchesOmittedExtraArgument() + { + var (catalog, map) = Fixtures.Vanilla(); + var (walks, pupil, schoolClass, table) = ComingPupil(catalog, map); + var implied = DayPlans.Build(catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9); + var explicitZero = DayPlans.Build( + catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9, extraCommuteMinutes: 0); + + Assert.Equal(implied, explicitZero); + Assert.True(implied.Comes); + } + + [Fact] + public void SameSeedAndSnowMinutes_YieldTheSameAppearAt() + { + var (catalog, map) = Fixtures.Vanilla(); + var (walks, pupil, schoolClass, table) = ComingPupil(catalog, map); + var extra = catalog.BehaviorRules!.CommuteSnowMinutes; + var first = DayPlans.Build( + catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9, extra); + var second = DayPlans.Build( + catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9, extra); + + Assert.Equal(first.AppearAt, second.AppearAt); + Assert.True(first.Comes); + } + + [Fact] + public void Sources_DoNotMentionPrecipitation() + { + var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.Ai"); + foreach (var path in Directory.EnumerateFiles(root, "*.cs")) + { + var text = File.ReadAllText(path); + Assert.DoesNotContain("Precipitation", text, StringComparison.Ordinal); + } + } + [Fact] public void Assembly_DoesNotReferenceArchAspNetOrSockets() { @@ -153,4 +208,29 @@ public class WalkingTests Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); } } + + private static (DayPlan Clear, DayPlan Weather, int Extra) PlansWithExtra(Func extraOf) + { + var (catalog, map) = Fixtures.Vanilla(); + var (walks, pupil, schoolClass, table) = ComingPupil(catalog, map); + var extra = extraOf(catalog); + var clear = DayPlans.Build(catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9); + var weather = DayPlans.Build( + catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9, extra); + return (clear, weather, extra); + } + + private static (WalkGraph Walks, Person Pupil, SchoolClass SchoolClass, Timetable Table) ComingPupil( + DefCatalog catalog, + MapLayout map) + { + var walks = WalkGraph.Build(catalog, map); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Russia", TuesdayLesson); + var pupil = roster.People.First(person => person.IsStudent); + var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId); + var table = new Timetable( + [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1)], + []); + return (walks, pupil, schoolClass, table); + } } diff --git a/tests/HSchool.Content.Tests/BehaviorDefTests.cs b/tests/HSchool.Content.Tests/BehaviorDefTests.cs index 5ebbb9e..6e34ada 100644 --- a/tests/HSchool.Content.Tests/BehaviorDefTests.cs +++ b/tests/HSchool.Content.Tests/BehaviorDefTests.cs @@ -97,6 +97,42 @@ public class BehaviorDefTests Assert.Contains("lessonNoTextbookFactor", error.Message, StringComparison.Ordinal); } + [Fact] + public void MissingCommuteWeatherMinutes_DefaultToZero() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "needThreshold": 0.35, "lessonSkillPerHour": 0.05, "commuteSlackMin": 0, "commuteSlackMax": 6, "switchMargin": 0.15 }"""), + ]); + + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(0, catalog.BehaviorRules.CommuteRainMinutes); + Assert.Equal(0, catalog.BehaviorRules.CommuteSnowMinutes); + } + + [Theory] + [InlineData("commuteRainMinutes")] + [InlineData("commuteSnowMinutes")] + public void NegativeCommuteWeatherMinutes_FailsTheCatalog(string field) + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + $$"""{ "defName": "Behavior", "{{field}}": -1 }"""), + ])); + + Assert.Contains("cannot be negative", error.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void NegativeApparelWear_FailsTheCatalog() { diff --git a/tests/HSchool.Simulation.Tests/WeatherTests.cs b/tests/HSchool.Simulation.Tests/WeatherTests.cs index e2a57f7..ba29180 100644 --- a/tests/HSchool.Simulation.Tests/WeatherTests.cs +++ b/tests/HSchool.Simulation.Tests/WeatherTests.cs @@ -63,6 +63,16 @@ public class WeatherTests } } + [Fact] + public void ExtraCommuteMinutes_FollowTheStreet() + { + var rules = Vanilla().BehaviorRules!; + Assert.Equal(6, new OutdoorWeather(-5f, Precipitation.Snow).ExtraCommuteMinutes(rules)); + Assert.Equal(3, new OutdoorWeather(8f, Precipitation.Rain).ExtraCommuteMinutes(rules)); + Assert.Equal(0, new OutdoorWeather(8f, Precipitation.None).ExtraCommuteMinutes(rules)); + Assert.Equal(0, new OutdoorWeather(-5f, Precipitation.Snow).ExtraCommuteMinutes(null)); + } + [Fact] public void SkipEmpty_SetsMondayMorningWeather_NotSaturdays() {