Merge branch 'phase/53-weather-commute'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -67,7 +67,9 @@
|
||||
## Климат и погода
|
||||
|
||||
Погода — не картинка и не текст. На школе живёт состояние: **уличная температура** и **осадки**
|
||||
(нет / дождь / снег). Его видно у часов, и от него одеваются.
|
||||
(нет / дождь / снег). Его видно у часов, и от него одеваются. Дождь и снег ещё добавляют минуты
|
||||
к запасу на дорогу (`BehaviorDef.commuteRainMinutes` / `commuteSnowMinutes`): план дня получает
|
||||
уже число, симуляция считает его с улицы. Ходьбу по коридору это не режет.
|
||||
|
||||
Числа живут в `ClimatePresetDef`, не в коде. Страна только ссылается на id. Мод меняет зиму,
|
||||
не трогая имена.
|
||||
@@ -418,7 +420,7 @@
|
||||
| Страна | `CountryDef`: вложенные имена + ссылки на пресеты климата |
|
||||
| Create | Пикер страны вместо набора имён; язык — если у страны их несколько |
|
||||
| Климат | Пресет выбирается сидом из списка страны, на живой школе не меняется |
|
||||
| Погода | Температура и осадки; у часов; в кадре часов |
|
||||
| Погода | Температура и осадки; у часов; одежда; минуты на дорогу |
|
||||
| В здании | Теплее улицы всегда; комфорт — если нанят техник |
|
||||
| Техник | Комната + должность; холод летом и тепло зимой |
|
||||
| Нужда | `Warmth`; черты теплолюбивый / холодолюбивый |
|
||||
|
||||
@@ -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`
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -240,4 +240,4 @@
|
||||
| [50. Навык учителя](50-teacher-skill-gain.md) | ✅ | Соискатель 78 учит лучше, чем 41 |
|
||||
| [51. Тепло на уроке](51-warmth-lesson.md) | ✅ | Замёрзший учится хуже, как голодный |
|
||||
| [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост |
|
||||
| [53. Погода на дороге](53-weather-commute.md) | 🔄 | Снег и дождь добавляют минуты к приходу |
|
||||
| [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу |
|
||||
|
||||
@@ -10,6 +10,10 @@ public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime
|
||||
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 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -285,6 +285,15 @@ public sealed class BehaviorDef : Def
|
||||
|
||||
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>
|
||||
public float SwitchMargin { get; init; }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <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),
|
||||
short.MinValue,
|
||||
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.Clock.Time,
|
||||
school.SchoolWeekDays,
|
||||
school.PeopleSeed);
|
||||
school.PeopleSeed,
|
||||
school.Weather.ExtraCommuteMinutes(school.Catalog!.BehaviorRules));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
[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<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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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]
|
||||
public void SkipEmpty_SetsMondayMorningWeather_NotSaturdays()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user