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:
@@ -67,7 +67,9 @@
|
|||||||
## Климат и погода
|
## Климат и погода
|
||||||
|
|
||||||
Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки**
|
Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки**
|
||||||
(нет / дождь / снег). Его видно у часов, и от него одеваются.
|
(нет / дождь / снег). Его видно у часов, и от него одеваются. Дождь и снег ещё добавляют минуты
|
||||||
|
к запасу на дорогу (`BehaviorDef.commuteRainMinutes` / `commuteSnowMinutes`): план дня получает
|
||||||
|
уже число, симуляция считает его с улицы. Ходьбу по коридору это не режет.
|
||||||
|
|
||||||
Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму,
|
Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму,
|
||||||
не трогая имена.
|
не трогая имена.
|
||||||
@@ -418,7 +420,7 @@
|
|||||||
| Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата |
|
| Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата |
|
||||||
| Create | Пикер страны вместо набора имён; язык — если у страны их несколько |
|
| Create | Пикер страны вместо набора имён; язык — если у страны их несколько |
|
||||||
| Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется |
|
| Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется |
|
||||||
| Погода | Температура и осадки; у часов; в кадре часов |
|
| Погода | Температура и осадки; у часов; одежда; минуты на дорогу |
|
||||||
| В здании | Теплее улицы всегда; комфорт — если нанят техник |
|
| В здании | Теплее улицы всегда; комфорт — если нанят техник |
|
||||||
| Техник | Комната + должность; холод летом и тепло зимой |
|
| Техник | Комната + должность; холод летом и тепло зимой |
|
||||||
| Нужда | `Warmth`; черты теплолюбивый / холодолюбивый |
|
| Нужда | `Warmth`; черты теплолюбивый / холодолюбивый |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user