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()
{