Add rain and snow minutes to the morning commute.

Day plans take a precomputed extra; Ai does not know precipitation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 09:40:24 +03:00
co-authored by Cursor
parent fbac890be2
commit ce71a91a67
11 changed files with 189 additions and 16 deletions
+4 -2
View File
@@ -67,7 +67,9 @@
## Климат и погода ## Климат и погода
Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки** Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки**
(нет / дождь / снег). Его видно у часов, и от него одеваются. (нет / дождь / снег). Его видно у часов, и от него одеваются. Дождь и снег ещё добавляют минуты
к запасу на дорогу (`BehaviorDef.commuteRainMinutes` / `commuteSnowMinutes`): план дня получает
уже число, симуляция считает его с улицы. Ходьбу по коридору это не режет.
Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму, Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму,
не трогая имена. не трогая имена.
@@ -418,7 +420,7 @@
| Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата | | Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата |
| Create | Пикер страны вместо набора имён; язык — если у страны их несколько | | Create | Пикер страны вместо набора имён; язык — если у страны их несколько |
| Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется | | Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется |
| Погода | Температура и осадки; у часов; в кадре часов | | Погода | Температура и осадки; у часов; одежда; минуты на дорогу |
| В здании | Теплее улицы всегда; комфорт — если нанят техник | | В здании | Теплее улицы всегда; комфорт — если нанят техник |
| Техник | Комната + должность; холод летом и тепло зимой | | Техник | Комната + должность; холод летом и тепло зимой |
| Нужда | `Warmth`; черты теплолюбивый / холодолюбивый | | Нужда | `Warmth`; черты теплолюбивый / холодолюбивый |
+11 -11
View File
@@ -11,22 +11,22 @@
## Задачи ## Задачи
- [ ] `BehaviorDef.commuteRainMinutes` и `commuteSnowMinutes`; ваниль 3 и 6 - [x] `BehaviorDef.commuteRainMinutes` и `commuteSnowMinutes`; ваниль 3 и 6
- [ ] Валидатор: не отрицательные - [x] Валидатор: не отрицательные
- [ ] `DayPlans.Build` принимает уже посчитанные лишние минуты; `HSchool.Ai` не знает осадков - [x] `DayPlans.Build` принимает уже посчитанные лишние минуты; `HSchool.Ai` не знает осадков
- [ ] Симуляция при сборке плана дня подставляет минуты из текущей улицы - [x] Симуляция при сборке плана дня подставляет минуты из текущей улицы
- [ ] Лог на каждого не пишем — опоздание видно местом - [x] Лог на каждого не пишем — опоздание видно местом
- [ ] В [`inventory.md`](../design/inventory.md) поправка: погода влияет на дорогу, не только - [x] В [`inventory.md`](../design/inventory.md) поправка: погода влияет на дорогу, не только
на переодевание на переодевание
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Снег → `AppearAt` раньше на `commuteSnowMinutes` относительно ясного того же дня - [x] Снег → `AppearAt` раньше на `commuteSnowMinutes` относительно ясного того же дня
(тот же человек, сид, дата без осадков) (тот же человек, сид, дата без осадков)
- [ ] Дождь → на `commuteRainMinutes` - [x] Дождь → на `commuteRainMinutes`
- [ ] Ясно → минуты как сейчас, только черты и бросок - [x] Ясно → минуты как сейчас, только черты и бросок
- [ ] Пак без полей — 0 лишних минут, каталог не падает - [x] Пак без полей — 0 лишних минут, каталог не падает
- [ ] Тот же сид и снег → тот же `AppearAt` - [x] Тот же сид и снег → тот же `AppearAt`
## Критерий готовности ## Критерий готовности
+7 -2
View File
@@ -10,6 +10,10 @@ public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime
public bool Comes => AppearAt is not null; public bool Comes => AppearAt is not null;
} }
/// <summary>
/// Day plans. Rain and snow arrive as already-counted extra minutes — this project does not
/// know precipitation.
/// </summary>
public static class DayPlans public static class DayPlans
{ {
public static DayPlan Build( public static DayPlan Build(
@@ -20,7 +24,8 @@ public static class DayPlans
Timetable? timetable, Timetable? timetable,
DateTime time, DateTime time,
int weekDays, int weekDays,
int schoolSeed) int schoolSeed,
int extraCommuteMinutes = 0)
{ {
ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(walks); ArgumentNullException.ThrowIfNull(walks);
@@ -48,7 +53,7 @@ public static class DayPlans
travel = 0f; 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 appear = DateTime.SpecifyKind(utc.Date.Add(firstStart.ToTimeSpan()).AddMinutes(-(travel + slack)), DateTimeKind.Utc);
var walkHome = DateTime.SpecifyKind(utc.Date.Add(lastEnd.ToTimeSpan()), DateTimeKind.Utc); var walkHome = DateTime.SpecifyKind(utc.Date.Add(lastEnd.ToTimeSpan()), DateTimeKind.Utc);
return new DayPlan(day, appear, walkHome, firstRoom); return new DayPlan(day, appear, walkHome, firstRoom);
@@ -522,6 +522,12 @@ internal static class PeopleDefValidator
$"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max."); $"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 if (behavior.DutyLessonWeight < 0f
|| behavior.DutyTravelWeight < 0f || behavior.DutyTravelWeight < 0f
|| behavior.NeedWeightAtZero < 0f || behavior.NeedWeightAtZero < 0f
+9
View File
@@ -285,6 +285,15 @@ public sealed class BehaviorDef : Def
public int CommuteSlackMax { get; init; } public int CommuteSlackMax { get; init; }
/// <summary>
/// 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.
/// </summary>
public int CommuteRainMinutes { get; init; }
/// <summary>Same as <see cref="CommuteRainMinutes"/> when the street is snow.</summary>
public int CommuteSnowMinutes { get; init; }
/// <summary>A new goal must beat the current one by this much before the person switches.</summary> /// <summary>A new goal must beat the current one by this much before the person switches.</summary>
public float SwitchMargin { get; init; } public float SwitchMargin { get; init; }
@@ -10,6 +10,9 @@
// the same seed still arrives at the same minute. // the same seed still arrives at the same minute.
"commuteSlackMin": 0, "commuteSlackMin": 0,
"commuteSlackMax": 6, "commuteSlackMax": 6,
// Extra minutes on the road when the street is wet. Snow is slower than rain.
"commuteRainMinutes": 3,
"commuteSnowMinutes": 6,
"switchMargin": 0.15, "switchMargin": 0.15,
// Lesson or posted work. Beats leisure (3) and a need that only just crossed // Lesson or posted work. Beats leisure (3) and a need that only just crossed
// the threshold; a toilet at zero (20) still leaves class. // the threshold; a toilet at zero (20) still leaves class.
+21
View File
@@ -1,3 +1,5 @@
using HSchool.Content;
namespace HSchool.Simulation; namespace HSchool.Simulation;
/// <summary>Street precipitation. Below 0 °C the same roll is snow, above it is rain.</summary> /// <summary>Street precipitation. Below 0 °C the same roll is snow, above it is rain.</summary>
@@ -17,4 +19,23 @@ public readonly record struct OutdoorWeather(float TemperatureC, Precipitation P
Math.Round(TemperatureC * 10d, MidpointRounding.AwayFromZero), Math.Round(TemperatureC * 10d, MidpointRounding.AwayFromZero),
short.MinValue, short.MinValue,
short.MaxValue); short.MaxValue);
/// <summary>
/// Extra commute minutes for today's street. Ai receives this number; it does not see
/// precipitation. A pack without the fields adds nothing.
/// </summary>
public int ExtraCommuteMinutes(BehaviorDef? rules)
{
if (rules is null)
{
return 0;
}
return Precipitation switch
{
Precipitation.Rain => rules.CommuteRainMinutes,
Precipitation.Snow => rules.CommuteSnowMinutes,
_ => 0,
};
}
} }
+2 -1
View File
@@ -217,7 +217,8 @@ internal static class PresenceSystem
school.Timetable, school.Timetable,
school.Clock.Time, school.Clock.Time,
school.SchoolWeekDays, school.SchoolWeekDays,
school.PeopleSeed); school.PeopleSeed,
school.Weather.ExtraCommuteMinutes(school.Catalog!.BehaviorRules));
} }
school.DecisionQueue.Clear(); school.DecisionQueue.Clear();
+80
View File
@@ -133,6 +133,61 @@ public class WalkingTests
Assert.True(first.AppearAt < DateTime.SpecifyKind(TuesdayLesson.Date.Add(new TimeSpan(8, 30, 0)), DateTimeKind.Utc)); 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] [Fact]
public void Assembly_DoesNotReferenceArchAspNetOrSockets() public void Assembly_DoesNotReferenceArchAspNetOrSockets()
{ {
@@ -153,4 +208,29 @@ public class WalkingTests
Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal);
} }
} }
private static (DayPlan Clear, DayPlan Weather, int Extra) PlansWithExtra(Func<DefCatalog, int> 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);
}
} }
@@ -97,6 +97,42 @@ public class BehaviorDefTests
Assert.Contains("lessonNoTextbookFactor", error.Message, StringComparison.Ordinal); 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<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"behavior",
"rules",
$$"""{ "defName": "Behavior", "{{field}}": -1 }"""),
]));
Assert.Contains("cannot be negative", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public void NegativeApparelWear_FailsTheCatalog() public void NegativeApparelWear_FailsTheCatalog()
{ {
@@ -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] [Fact]
public void SkipEmpty_SetsMondayMorningWeather_NotSaturdays() public void SkipEmpty_SetsMondayMorningWeather_NotSaturdays()
{ {