From ce772cd3c33de7dcef6ff9466567a704e281423f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 04:44:41 +0300 Subject: [PATCH] Phase 33: technician climate control and locker assignment. --- docs/phases/33-lockers-technician.md | 28 ++-- docs/protocol.md | 3 +- src/HSchool.Content/Defs.cs | 5 + src/HSchool.People/LockerAssigner.cs | 69 ++++++++++ src/HSchool.People/Roster.cs | 3 + src/HSchool.People/RosterGenerator.cs | 2 +- src/HSchool.Server/Game/PersonCardReader.cs | 3 +- src/HSchool.Server/Game/SchoolWorker.cs | 2 + .../mods/core/defs/positions/staff.jsonc | 1 + .../mods/core/defs/rooms/service.jsonc | 21 ++- .../mods/core/defs/works/works.jsonc | 1 + .../mods/core/localizations/en.jsonc | 6 +- .../mods/core/localizations/ru.jsonc | 6 +- .../mods/core/maps/default.jsonc | 27 +++- src/HSchool.Simulation/PlaceClimate.cs | 25 +++- src/HSchool.Simulation/School.cs | 5 + .../ChangingRoomMapTests.cs | 27 ++++ .../LockerAssignerTests.cs | 127 ++++++++++++++++++ .../TechnicianClimateTests.cs | 99 ++++++++++++++ 19 files changed, 436 insertions(+), 24 deletions(-) create mode 100644 src/HSchool.People/LockerAssigner.cs create mode 100644 tests/HSchool.Content.Tests/ChangingRoomMapTests.cs create mode 100644 tests/HSchool.People.Tests/LockerAssignerTests.cs create mode 100644 tests/HSchool.Simulation.Tests/TechnicianClimateTests.cs diff --git a/docs/phases/33-lockers-technician.md b/docs/phases/33-lockers-technician.md index f3e644a..b3cbde5 100644 --- a/docs/phases/33-lockers-technician.md +++ b/docs/phases/33-lockers-technician.md @@ -13,28 +13,28 @@ ## Задачи -- [ ] `RoomDef` теплоузла и `PositionDef Technician`; комната открывает должность, как медкабинет -- [ ] Ванильная карта: комната в основном корпусе, связана с коридором -- [ ] Нанятый техник сдвигает внутреннюю температуру в комфортную полосу; без него здание +- [x] `RoomDef` теплоузла и `PositionDef Technician`; комната открывает должность, как медкабинет +- [x] Ванильная карта: комната в основном корпусе, связана с коридором +- [x] Нанятый техник сдвигает внутреннюю температуру в комфортную полосу; без него здание только демпфирует улицу. Числа — в дефе правил климата -- [ ] Две раздевалки у спортзала: мужская и женская, разные def, свои шкафчики. Старый один +- [x] Две раздевалки у спортзала: мужская и женская, разные def, свои шкафчики. Старый один узел `changing-room` заменяется -- [ ] Раздача: ученики этого пола, старшая параллель раньше, при равенстве id. Сотрудникам +- [x] Раздача: ученики этого пола, старшая параллель раньше, при равенстве id. Сотрудникам шкафчик не дают. Не хватило — место `locker` пустое, вещи в сумке и дома -- [ ] Пересчёт раздачи на годовом наборе (параллели сдвинулись) -- [ ] Редактор: число шкафчиков в слоте по-прежнему меняется; после создания школы раскладка +- [x] Пересчёт раздачи на годовом наборе (параллели сдвинулись) +- [x] Редактор: число шкафчиков в слоте по-прежнему меняется; после создания школы раскладка заморожена, как вся карта -- [ ] Карточка показывает, есть ли шкафчик (ещё без вкладки ноши) +- [x] Карточка показывает, есть ли шкафчик (ещё без вкладки ноши) ## Тесты, без которых фаза не закрыта -- [ ] Новая школа без техника: январский кабинет холоднее, чем с нанятым техником, и теплее +- [x] Новая школа без техника: январский кабинет холоднее, чем с нанятым техником, и теплее улицы -- [ ] Нанятый техник в июле держит кабинет ниже раскалённой улицы -- [ ] Две раздевалки на ванильной карте, граф связен -- [ ] Шкафчиков 10 на 40 учениц — получают десять старших; одиннадцатая без шкафчика -- [ ] Мальчик не получает слот девичьей раздевалки -- [ ] После 1 сентября раздача пересчитывается, выпускник слот не держит +- [x] Нанятый техник в июле держит кабинет ниже раскалённой улицы +- [x] Две раздевалки на ванильной карте, граф связен +- [x] Шкафчиков 10 на 40 учениц — получают десять старших; одиннадцатая без шкафчика +- [x] Мальчик не получает слот девичьей раздевалки +- [x] После 1 сентября раздача пересчитывается, выпускник слот не держит ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 74a99b9..941c99b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -259,7 +259,8 @@ no Chemistry; a related tongue from the name set may sit beside the native at a (0–1) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` / `висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds. `carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; -overload does not slow walking. `hasLocker` is true when anything sits in a locker; `homeCount` +overload does not slow walking. `hasLocker` is true when the pupil has an assigned locker slot +(`lockerRoomId`) or anything sits in a locker; `homeCount` is how many items remain at home, not the list. The people list does not include any of these fields. Today's history is a separate GET. diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 7194570..c6bc4e7 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -154,6 +154,11 @@ public sealed class RoomDef : Def /// the street by the climate preset's wall offset. /// public bool Outdoor { get; init; } + + /// + /// When set, pupil lockers in this room go to students of that sex (male / female). + /// + public string? LockerSex { get; init; } } public sealed class BuildingDef : Def; diff --git a/src/HSchool.People/LockerAssigner.cs b/src/HSchool.People/LockerAssigner.cs new file mode 100644 index 0000000..f8d52ff --- /dev/null +++ b/src/HSchool.People/LockerAssigner.cs @@ -0,0 +1,69 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// +/// Hands out personal locker slots from each gendered changing room. Senior years first, then id. +/// Staff never receive a slot; graduates lose theirs on the next assignment pass. +/// +public static class LockerAssigner +{ + public static Roster Apply(DefCatalog catalog, MapLayout map, Roster roster) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(roster); + + var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + foreach (var id in people.Keys.ToArray()) + { + people[id] = people[id] with { LockerRoomId = null }; + } + + foreach (var room in map.Rooms) + { + if (!catalog.Rooms.TryGetValue(room.Def, out var def) || def.LockerSex is null) + { + continue; + } + + var female = def.LockerSex.Equals("female", StringComparison.OrdinalIgnoreCase); + if (!female && !def.LockerSex.Equals("male", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var lockerCount = RoomOccupancy.ThingCount(catalog, map, room.Id, "Locker"); + if (lockerCount <= 0) + { + continue; + } + + var winners = people.Values + .Where(person => person.IsStudent && person.Female == female) + .Select(person => + { + var year = 0; + if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)) + { + year = schoolClass.Year; + } + + return (Person: person, Year: year); + }) + .OrderByDescending(entry => entry.Year) + .ThenBy(entry => entry.Person.Id, StringComparer.Ordinal) + .Take(lockerCount) + .Select(entry => entry.Person.Id) + .ToArray(); + + foreach (var id in winners) + { + people[id] = people[id] with { LockerRoomId = room.Id }; + } + } + + return new Roster([.. people.Values], roster.Families, roster.Classes); + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 9b3cee0..8a917b3 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -49,6 +49,9 @@ public sealed record Person /// Worn, bag, locker and home. Empty on rosters written before clothes existed. public IReadOnlyList Items { get; init; } = []; + /// Changing-room node when this pupil won a locker slot. Null for staff and the rest. + public string? LockerRoomId { get; init; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 0c9da70..4699b81 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -44,7 +44,7 @@ public static class RosterGenerator } var classes = FillClasses(demand.Classes, people); - return new Roster(people, families, classes); + return LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); } /// diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 1c5827f..fb66f50 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -86,7 +86,8 @@ internal static class PersonCardReader Carried(person, catalog, locale), catalog is null ? 0f : CarryMass.Held(catalog, person.Items), Capacity(person, skills, catalog), - person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), + person.LockerRoomId is not null + || person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal))); } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 5815ee8..930302a 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -945,6 +945,8 @@ internal sealed class SchoolWorker generated = true; } + roster = LockerAssigner.Apply(catalog, map, roster); + if (!RosterFit.Matches(roster, demand)) { throw new SchoolContentUnavailableException( diff --git a/src/HSchool.Server/mods/core/defs/positions/staff.jsonc b/src/HSchool.Server/mods/core/defs/positions/staff.jsonc index 239e028..0677d09 100644 --- a/src/HSchool.Server/mods/core/defs/positions/staff.jsonc +++ b/src/HSchool.Server/mods/core/defs/positions/staff.jsonc @@ -5,4 +5,5 @@ { "defName": "Librarian" }, { "defName": "Nurse" }, { "defName": "CafeteriaCook" }, + { "defName": "Technician" }, ] diff --git a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc index ed1990e..3a9f5e8 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc @@ -42,7 +42,26 @@ "travelMinutes": 0.5, }, { - "defName": "ChangingRoom", + "defName": "UtilityRoom", + "slots": [ + { "key": "desk", "thing": "Desk" }, + { "key": "chair", "thing": "Chair" }, + ], + "positions": ["Technician"], + "works": ["ClimateDuty"], + "travelMinutes": 0.5, + }, + { + "defName": "MaleChangingRoom", + "lockerSex": "male", + "slots": [ + { "key": "lockers", "thing": "Locker", "count": 12 }, + ], + "travelMinutes": 0.5, + }, + { + "defName": "FemaleChangingRoom", + "lockerSex": "female", "slots": [ { "key": "lockers", "thing": "Locker", "count": 12 }, ], diff --git a/src/HSchool.Server/mods/core/defs/works/works.jsonc b/src/HSchool.Server/mods/core/defs/works/works.jsonc index 0edc6ec..3145bc1 100644 --- a/src/HSchool.Server/mods/core/defs/works/works.jsonc +++ b/src/HSchool.Server/mods/core/defs/works/works.jsonc @@ -5,6 +5,7 @@ { "defName": "OfficeWork" }, { "defName": "LibraryWork" }, { "defName": "MedicalDuty" }, + { "defName": "ClimateDuty" }, { "defName": "PELesson" }, { "defName": "ServeLunch" }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 530dc15..9be1656 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -63,6 +63,7 @@ "Teacher": "Teacher", "Librarian": "Librarian", "Nurse": "Nurse", + "Technician": "Technician", "CafeteriaCook": "Cook", "PrimarySchool": "Primary", "PrincipalOfficeWork": "Principal's work", @@ -71,6 +72,7 @@ "OfficeWork": "Office work", "LibraryWork": "Library work", "MedicalDuty": "Nurse's duty", + "ClimateDuty": "Climate control", "PELesson": "PE lesson", "ServeLunch": "Serve lunch", "SchoolYard": "Yard", @@ -90,7 +92,9 @@ "Library": "Library", "ComputerLab": "Computer lab", "GymHall": "Gym", - "ChangingRoom": "Changing room", + "UtilityRoom": "Utility room", + "MaleChangingRoom": "Boys' changing room", + "FemaleChangingRoom": "Girls' changing room", "Mathematics": "Mathematics", "RussianLanguage": "Russian", "Literature": "Literature", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 085a25d..7e49a91 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -63,6 +63,7 @@ "Teacher": "Учитель", "Librarian": "Библиотекарь", "Nurse": "Медсестра", + "Technician": "Техник", "CafeteriaCook": "Повар", "PrimarySchool": "Начальные классы", "PrincipalOfficeWork": "Работа директора", @@ -71,6 +72,7 @@ "OfficeWork": "Канцелярия", "LibraryWork": "Работа в библиотеке", "MedicalDuty": "Медпункт", + "ClimateDuty": "Климат", "PELesson": "Урок физкультуры", "ServeLunch": "Раздача обеда", "SchoolYard": "Двор", @@ -90,7 +92,9 @@ "Library": "Библиотека", "ComputerLab": "Компьютерный класс", "GymHall": "Спортивный зал", - "ChangingRoom": "Раздевалка", + "UtilityRoom": "Теплоузел", + "MaleChangingRoom": "Мужская раздевалка", + "FemaleChangingRoom": "Женская раздевалка", "Mathematics": "Математика", "RussianLanguage": "Русский язык", "Literature": "Литература", diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc index 4d98cbe..b66aabf 100644 --- a/src/HSchool.Server/mods/core/maps/default.jsonc +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -99,6 +99,16 @@ { "key": "desk", "thing": "Desk" }, ], }, + { + "id": "utility-room", + "def": "UtilityRoom", + "building": "main", + "floor": "floor-1", + "slots": [ + { "key": "desk", "thing": "Desk" }, + { "key": "chair", "thing": "Chair" }, + ], + }, { "id": "corridor-2", "def": "Corridor", "building": "main", "floor": "floor-2", "label": "2" }, { "id": "stairs-2", "def": "Stairwell", "building": "main", "floor": "floor-2", "label": "2" }, { @@ -189,8 +199,17 @@ ], }, { - "id": "changing-room", - "def": "ChangingRoom", + "id": "boys-changing-room", + "def": "MaleChangingRoom", + "building": "gym", + "floor": "gym-floor", + "slots": [ + { "key": "lockers", "thing": "Locker", "count": 12 }, + ], + }, + { + "id": "girls-changing-room", + "def": "FemaleChangingRoom", "building": "gym", "floor": "gym-floor", "slots": [ @@ -212,6 +231,7 @@ { "a": "corridor-1", "b": "cafeteria" }, { "a": "corridor-1", "b": "restroom-1" }, { "a": "corridor-1", "b": "medical-office" }, + { "a": "corridor-1", "b": "utility-room" }, { "a": "stairs-1", "b": "stairs-2" }, { "a": "stairs-2", "b": "corridor-2" }, { "a": "corridor-2", "b": "classroom-201" }, @@ -225,6 +245,7 @@ { "a": "corridor-2", "b": "computer-lab" }, { "a": "corridor-2", "b": "restroom-2" }, { "a": "yard", "b": "gym-hall" }, - { "a": "gym-hall", "b": "changing-room" }, + { "a": "gym-hall", "b": "boys-changing-room" }, + { "a": "gym-hall", "b": "girls-changing-room" }, ], } diff --git a/src/HSchool.Simulation/PlaceClimate.cs b/src/HSchool.Simulation/PlaceClimate.cs index d2c7ee1..776f41b 100644 --- a/src/HSchool.Simulation/PlaceClimate.cs +++ b/src/HSchool.Simulation/PlaceClimate.cs @@ -45,13 +45,36 @@ public static class PlaceClimate } var offset = 8f; + ClimatePresetDef? climate = null; if (school.ClimatePresetId is { } presetId && catalog.ClimatePresets.TryGetValue(presetId, out var preset) && !preset.Abstract) { offset = preset.IndoorOffset; + climate = preset; } - return outdoor + offset; + var indoor = outdoor + offset; + if (HasTechnician(school) && climate is not null) + { + var min = climate.ComfortC - climate.ComfortHalfWidthC; + var max = climate.ComfortC + climate.ComfortHalfWidthC; + if (indoor < min) + { + return min; + } + + if (indoor > max) + { + return max; + } + } + + return indoor; } + + public static bool HasTechnician(School school) => + school.Roster?.People.Any(person => + person.IsStaff && person.Position is { } position + && position.Equals("Technician", StringComparison.Ordinal)) == true; } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 83ffe66..9c3d47c 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -367,6 +367,11 @@ public sealed class School : IDisposable foreach (var date in YearlyIntake.DatesBetween(before, after)) { Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, CountryId, date, NativeLanguage); + if (Map is not null) + { + Roster = LockerAssigner.Apply(Catalog, Map, Roster); + } + changed = true; } diff --git a/tests/HSchool.Content.Tests/ChangingRoomMapTests.cs b/tests/HSchool.Content.Tests/ChangingRoomMapTests.cs new file mode 100644 index 0000000..a378963 --- /dev/null +++ b/tests/HSchool.Content.Tests/ChangingRoomMapTests.cs @@ -0,0 +1,27 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class ChangingRoomMapTests +{ + [Fact] + public void VanillaMap_HasTwoChangingRooms_AndGraphIsConnected() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root); + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + + MapValidator.Validate(map, catalog); + + var changing = map.Rooms + .Where(room => room.Def is "MaleChangingRoom" or "FemaleChangingRoom") + .ToList(); + Assert.Equal(2, changing.Count); + Assert.Contains(map.Rooms, room => room.Id == "boys-changing-room" && room.Def == "MaleChangingRoom"); + Assert.Contains(map.Rooms, room => room.Id == "girls-changing-room" && room.Def == "FemaleChangingRoom"); + Assert.Contains(map.Rooms, room => room.Id == "utility-room" && room.Def == "UtilityRoom"); + Assert.Equal(["Technician"], catalog.PositionsFor(DefKind.Room, "UtilityRoom")); + } +} diff --git a/tests/HSchool.People.Tests/LockerAssignerTests.cs b/tests/HSchool.People.Tests/LockerAssignerTests.cs new file mode 100644 index 0000000..081e69c --- /dev/null +++ b/tests/HSchool.People.Tests/LockerAssignerTests.cs @@ -0,0 +1,127 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class LockerAssignerTests +{ + private static readonly DateTime September = new(2012, 9, 1, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void TenLockersForFortyGirls_SeniorsFirst_EleventhHasNone() + { + var catalog = Fixtures.Catalog(); + var map = WithGirlLockerCount(Fixtures.VanillaMap(), 10); + var roster = Fixtures.Generate(map); + roster = LockerAssigner.Apply(catalog, map, roster); + + var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + var girls = roster.People + .Where(person => person.IsStudent && person.Female) + .Select(person => + { + var year = person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass) + ? schoolClass.Year + : 0; + return (Person: person, Year: year); + }) + .OrderByDescending(entry => entry.Year) + .ThenBy(entry => entry.Person.Id, StringComparer.Ordinal) + .ToArray(); + + Assert.True(girls.Length >= 11, $"need at least eleven girls, got {girls.Length}"); + + var withLocker = girls + .Where(entry => entry.Person.LockerRoomId is not null) + .ToArray(); + Assert.Equal(10, withLocker.Length); + Assert.All(withLocker, entry => Assert.Equal("girls-changing-room", entry.Person.LockerRoomId)); + Assert.Null(girls[10].Person.LockerRoomId); + } + + [Fact] + public void Boy_DoesNotReceiveTheGirlsChangingRoomSlot() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + var roster = LockerAssigner.Apply(catalog, map, Fixtures.Generate(map)); + + foreach (var boy in roster.People.Where(person => person.IsStudent && !person.Female)) + { + Assert.NotEqual("girls-changing-room", boy.LockerRoomId); + if (boy.LockerRoomId is not null) + { + Assert.Equal("boys-changing-room", boy.LockerRoomId); + } + } + } + + [Fact] + public void AfterSeptemberIntake_GraduateDoesNotKeepASlot() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + var before = LockerAssigner.Apply(catalog, map, Fixtures.Generate(map)); + var maxYear = before.Classes.Max(schoolClass => schoolClass.Year); + var graduate = before.People.First(person => + person.IsStudent + && person.Female + && person.LockerRoomId is not null + && person.ClassId is { } classId + && before.Classes.Single(schoolClass => schoolClass.Id == classId).Year == maxYear); + + var after = LockerAssigner.Apply( + catalog, + map, + YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Russia", September)); + + Assert.DoesNotContain(after.People, person => person.Id.Equals(graduate.Id, StringComparison.Ordinal)); + } + + [Fact] + public void Staff_NeverReceiveLockerSlots() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + var roster = Fixtures.Generate(map); + var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 100_000f); + var assigned = LockerAssigner.Apply(catalog, map, hired.Roster); + + Assert.All( + assigned.People.Where(person => person.IsStaff), + person => Assert.Null(person.LockerRoomId)); + } + + private static MapLayout WithGirlLockerCount(MapLayout map, int count) + { + var rooms = map.Rooms + .Select(room => + { + if (!room.Id.Equals("girls-changing-room", StringComparison.Ordinal)) + { + return room; + } + + return new RoomNode + { + Id = room.Id, + Def = room.Def, + Building = room.Building, + Floor = room.Floor, + Label = room.Label, + Seats = room.Seats, + Slots = [new SlotFill { Key = "lockers", Thing = "Locker", Count = count }], + }; + }) + .ToArray(); + + return new MapLayout + { + Territory = map.Territory, + Buildings = map.Buildings, + Floors = map.Floors, + Rooms = rooms, + Links = map.Links, + }; + } +} diff --git a/tests/HSchool.Simulation.Tests/TechnicianClimateTests.cs b/tests/HSchool.Simulation.Tests/TechnicianClimateTests.cs new file mode 100644 index 0000000..330db5c --- /dev/null +++ b/tests/HSchool.Simulation.Tests/TechnicianClimateTests.cs @@ -0,0 +1,99 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation.Tests; + +public class TechnicianClimateTests +{ + private static readonly DateTime JanuaryMorning = new(2012, 1, 15, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime JulyAfternoon = new(2012, 7, 15, 15, 0, 0, DateTimeKind.Utc); + private const float Cap = 100_000f; + + [Fact] + public void WithoutTechnician_JanuaryClassroom_IsColdButWarmerThanTheYard() + { + using var school = Open(JanuaryMorning); + var outdoor = PlaceClimate.TemperatureC(school, "yard"); + var room = PlaceClimate.TemperatureC(school, "classroom-101"); + Assert.True(room > outdoor); + Assert.True(room < 18f, $"walls only, got {room}"); + } + + [Fact] + public void WithoutTechnician_JanuaryClassroom_IsColderThanWithHiredTechnician() + { + using var bare = Open(JanuaryMorning); + using var heated = Open(JanuaryMorning); + HireTechnician(heated); + + var bareRoom = PlaceClimate.TemperatureC(bare, "classroom-101"); + var heatedRoom = PlaceClimate.TemperatureC(heated, "classroom-101"); + Assert.True(bareRoom < heatedRoom, $"{bareRoom} vs {heatedRoom}"); + } + + [Fact] + public void HiredTechnician_InJuly_KeepsClassroomBelowTheHotStreet() + { + using var school = Open(JulyAfternoon); + var outdoor = school.Weather.TemperatureC; + Assert.True(outdoor > 25f, $"expected a hot July street, got {outdoor}"); + + var bareRoom = PlaceClimate.TemperatureC(school, "classroom-101"); + Assert.True(bareRoom > outdoor); + + HireTechnician(school); + var cooled = PlaceClimate.TemperatureC(school, "classroom-101"); + Assert.True(cooled < outdoor, $"{cooled} vs street {outdoor}"); + Assert.True(cooled <= 24f, $"comfort band top, got {cooled}"); + } + + private static void HireTechnician(School school) + { + var catalog = school.Catalog!; + var map = school.Map!; + var pool = school.Applicants!; + var hired = Staffing.Hire( + catalog, + map, + school.Roster!, + pool, + pool.Applicants[0].Person.Id, + "Technician", + Cap); + Assert.Equal(StaffingError.None, hired.Error); + school.ApplyStaffing(hired.Roster, hired.Pool); + } + + private static School Open(DateTime start) + { + var (catalog, map) = VanillaMap(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start); + var school = School.Create(1, "Климат", start, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool, climatePresetId: "TemperateContinental"); + school.ConfigurePresence(); + return school; + } + + private static (DefCatalog Catalog, MapLayout Map) VanillaMap() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}