Merge branch 'phase/33-lockers-technician'

This commit is contained in:
Leonid Pershin
2026-08-20 04:47:35 +03:00
20 changed files with 437 additions and 25 deletions
+14 -14
View File
@@ -13,28 +13,28 @@
## Задачи ## Задачи
- [ ] `RoomDef` теплоузла и `PositionDef Technician`; комната открывает должность, как медкабинет - [x] `RoomDef` теплоузла и `PositionDef Technician`; комната открывает должность, как медкабинет
- [ ] Ванильная карта: комната в основном корпусе, связана с коридором - [x] Ванильная карта: комната в основном корпусе, связана с коридором
- [ ] Нанятый техник сдвигает внутреннюю температуру в комфортную полосу; без него здание - [x] Нанятый техник сдвигает внутреннюю температуру в комфортную полосу; без него здание
только демпфирует улицу. Числа — в дефе правил климата только демпфирует улицу. Числа — в дефе правил климата
- [ ] Две раздевалки у спортзала: мужская и женская, разные def, свои шкафчики. Старый один - [x] Две раздевалки у спортзала: мужская и женская, разные def, свои шкафчики. Старый один
узел `changing-room` заменяется узел `changing-room` заменяется
- [ ] Раздача: ученики этого пола, старшая параллель раньше, при равенстве id. Сотрудникам - [x] Раздача: ученики этого пола, старшая параллель раньше, при равенстве id. Сотрудникам
шкафчик не дают. Не хватило — место `locker` пустое, вещи в сумке и дома шкафчик не дают. Не хватило — место `locker` пустое, вещи в сумке и дома
- [ ] Пересчёт раздачи на годовом наборе (параллели сдвинулись) - [x] Пересчёт раздачи на годовом наборе (параллели сдвинулись)
- [ ] Редактор: число шкафчиков в слоте по-прежнему меняется; после создания школы раскладка - [x] Редактор: число шкафчиков в слоте по-прежнему меняется; после создания школы раскладка
заморожена, как вся карта заморожена, как вся карта
- [ ] Карточка показывает, есть ли шкафчик (ещё без вкладки ноши) - [x] Карточка показывает, есть ли шкафчик (ещё без вкладки ноши)
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Новая школа без техника: январский кабинет холоднее, чем с нанятым техником, и теплее - [x] Новая школа без техника: январский кабинет холоднее, чем с нанятым техником, и теплее
улицы улицы
- [ ] Нанятый техник в июле держит кабинет ниже раскалённой улицы - [x] Нанятый техник в июле держит кабинет ниже раскалённой улицы
- [ ] Две раздевалки на ванильной карте, граф связен - [x] Две раздевалки на ванильной карте, граф связен
- [ ] Шкафчиков 10 на 40 учениц — получают десять старших; одиннадцатая без шкафчика - [x] Шкафчиков 10 на 40 учениц — получают десять старших; одиннадцатая без шкафчика
- [ ] Мальчик не получает слот девичьей раздевалки - [x] Мальчик не получает слот девичьей раздевалки
- [ ] После 1 сентября раздача пересчитывается, выпускник слот не держит - [x] После 1 сентября раздача пересчитывается, выпускник слот не держит
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -138,7 +138,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [32. Погода и тепло](32-weather-warmth.md) | ✅ | Пресет крутит улицу, кадр часов, нужда, две черты | | [32. Погода и тепло](32-weather-warmth.md) | ✅ | Пресет крутит улицу, кадр часов, нужда, две черты |
| [33. Техник и шкафчики](33-lockers-technician.md) | 🔄 | Комната техника, две раздевалки, раздача шкафчиков | | [33. Техник и шкафчики](33-lockers-technician.md) | | Комната техника, две раздевалки, раздача шкафчиков |
32 стоит на 29; 33 — на 31 и 32. 32 стоит на 29; 33 — на 31 и 32.
+2 -1
View File
@@ -259,7 +259,8 @@ no Chemistry; a related tongue from the name set may sit beside the native at a
(01) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` / (01) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` /
`висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds. `висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds.
`carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; `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 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. fields. Today's history is a separate GET.
+5
View File
@@ -154,6 +154,11 @@ public sealed class RoomDef : Def
/// the street by the climate preset's wall offset. /// the street by the climate preset's wall offset.
/// </summary> /// </summary>
public bool Outdoor { get; init; } public bool Outdoor { get; init; }
/// <summary>
/// When set, pupil lockers in this room go to students of that sex (<c>male</c> / <c>female</c>).
/// </summary>
public string? LockerSex { get; init; }
} }
public sealed class BuildingDef : Def; public sealed class BuildingDef : Def;
+69
View File
@@ -0,0 +1,69 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>
/// 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.
/// </summary>
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);
}
}
+3
View File
@@ -49,6 +49,9 @@ public sealed record Person
/// <summary>Worn, bag, locker and home. Empty on rosters written before clothes existed.</summary> /// <summary>Worn, bag, locker and home. Empty on rosters written before clothes existed.</summary>
public IReadOnlyList<InventoryItem> Items { get; init; } = []; public IReadOnlyList<InventoryItem> Items { get; init; } = [];
/// <summary>Changing-room node when this pupil won a locker slot. Null for staff and the rest.</summary>
public string? LockerRoomId { get; init; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
} }
+1 -1
View File
@@ -44,7 +44,7 @@ public static class RosterGenerator
} }
var classes = FillClasses(demand.Classes, people); var classes = FillClasses(demand.Classes, people);
return new Roster(people, families, classes); return LockerAssigner.Apply(catalog, map, new Roster(people, families, classes));
} }
/// <summary> /// <summary>
+2 -1
View File
@@ -86,7 +86,8 @@ internal static class PersonCardReader
Carried(person, catalog, locale), Carried(person, catalog, locale),
catalog is null ? 0f : CarryMass.Held(catalog, person.Items), catalog is null ? 0f : CarryMass.Held(catalog, person.Items),
Capacity(person, skills, catalog), 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))); person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)));
} }
+2
View File
@@ -945,6 +945,8 @@ internal sealed class SchoolWorker
generated = true; generated = true;
} }
roster = LockerAssigner.Apply(catalog, map, roster);
if (!RosterFit.Matches(roster, demand)) if (!RosterFit.Matches(roster, demand))
{ {
throw new SchoolContentUnavailableException( throw new SchoolContentUnavailableException(
@@ -5,4 +5,5 @@
{ "defName": "Librarian" }, { "defName": "Librarian" },
{ "defName": "Nurse" }, { "defName": "Nurse" },
{ "defName": "CafeteriaCook" }, { "defName": "CafeteriaCook" },
{ "defName": "Technician" },
] ]
@@ -42,7 +42,26 @@
"travelMinutes": 0.5, "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": [ "slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 }, { "key": "lockers", "thing": "Locker", "count": 12 },
], ],
@@ -5,6 +5,7 @@
{ "defName": "OfficeWork" }, { "defName": "OfficeWork" },
{ "defName": "LibraryWork" }, { "defName": "LibraryWork" },
{ "defName": "MedicalDuty" }, { "defName": "MedicalDuty" },
{ "defName": "ClimateDuty" },
{ "defName": "PELesson" }, { "defName": "PELesson" },
{ "defName": "ServeLunch" }, { "defName": "ServeLunch" },
] ]
@@ -63,6 +63,7 @@
"Teacher": "Teacher", "Teacher": "Teacher",
"Librarian": "Librarian", "Librarian": "Librarian",
"Nurse": "Nurse", "Nurse": "Nurse",
"Technician": "Technician",
"CafeteriaCook": "Cook", "CafeteriaCook": "Cook",
"PrimarySchool": "Primary", "PrimarySchool": "Primary",
"PrincipalOfficeWork": "Principal's work", "PrincipalOfficeWork": "Principal's work",
@@ -71,6 +72,7 @@
"OfficeWork": "Office work", "OfficeWork": "Office work",
"LibraryWork": "Library work", "LibraryWork": "Library work",
"MedicalDuty": "Nurse's duty", "MedicalDuty": "Nurse's duty",
"ClimateDuty": "Climate control",
"PELesson": "PE lesson", "PELesson": "PE lesson",
"ServeLunch": "Serve lunch", "ServeLunch": "Serve lunch",
"SchoolYard": "Yard", "SchoolYard": "Yard",
@@ -90,7 +92,9 @@
"Library": "Library", "Library": "Library",
"ComputerLab": "Computer lab", "ComputerLab": "Computer lab",
"GymHall": "Gym", "GymHall": "Gym",
"ChangingRoom": "Changing room", "UtilityRoom": "Utility room",
"MaleChangingRoom": "Boys' changing room",
"FemaleChangingRoom": "Girls' changing room",
"Mathematics": "Mathematics", "Mathematics": "Mathematics",
"RussianLanguage": "Russian", "RussianLanguage": "Russian",
"Literature": "Literature", "Literature": "Literature",
@@ -63,6 +63,7 @@
"Teacher": "Учитель", "Teacher": "Учитель",
"Librarian": "Библиотекарь", "Librarian": "Библиотекарь",
"Nurse": "Медсестра", "Nurse": "Медсестра",
"Technician": "Техник",
"CafeteriaCook": "Повар", "CafeteriaCook": "Повар",
"PrimarySchool": "Начальные классы", "PrimarySchool": "Начальные классы",
"PrincipalOfficeWork": "Работа директора", "PrincipalOfficeWork": "Работа директора",
@@ -71,6 +72,7 @@
"OfficeWork": "Канцелярия", "OfficeWork": "Канцелярия",
"LibraryWork": "Работа в библиотеке", "LibraryWork": "Работа в библиотеке",
"MedicalDuty": "Медпункт", "MedicalDuty": "Медпункт",
"ClimateDuty": "Климат",
"PELesson": "Урок физкультуры", "PELesson": "Урок физкультуры",
"ServeLunch": "Раздача обеда", "ServeLunch": "Раздача обеда",
"SchoolYard": "Двор", "SchoolYard": "Двор",
@@ -90,7 +92,9 @@
"Library": "Библиотека", "Library": "Библиотека",
"ComputerLab": "Компьютерный класс", "ComputerLab": "Компьютерный класс",
"GymHall": "Спортивный зал", "GymHall": "Спортивный зал",
"ChangingRoom": "Раздевалка", "UtilityRoom": "Теплоузел",
"MaleChangingRoom": "Мужская раздевалка",
"FemaleChangingRoom": "Женская раздевалка",
"Mathematics": "Математика", "Mathematics": "Математика",
"RussianLanguage": "Русский язык", "RussianLanguage": "Русский язык",
"Literature": "Литература", "Literature": "Литература",
@@ -99,6 +99,16 @@
{ "key": "desk", "thing": "Desk" }, { "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": "corridor-2", "def": "Corridor", "building": "main", "floor": "floor-2", "label": "2" },
{ "id": "stairs-2", "def": "Stairwell", "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", "id": "boys-changing-room",
"def": "ChangingRoom", "def": "MaleChangingRoom",
"building": "gym",
"floor": "gym-floor",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
],
},
{
"id": "girls-changing-room",
"def": "FemaleChangingRoom",
"building": "gym", "building": "gym",
"floor": "gym-floor", "floor": "gym-floor",
"slots": [ "slots": [
@@ -212,6 +231,7 @@
{ "a": "corridor-1", "b": "cafeteria" }, { "a": "corridor-1", "b": "cafeteria" },
{ "a": "corridor-1", "b": "restroom-1" }, { "a": "corridor-1", "b": "restroom-1" },
{ "a": "corridor-1", "b": "medical-office" }, { "a": "corridor-1", "b": "medical-office" },
{ "a": "corridor-1", "b": "utility-room" },
{ "a": "stairs-1", "b": "stairs-2" }, { "a": "stairs-1", "b": "stairs-2" },
{ "a": "stairs-2", "b": "corridor-2" }, { "a": "stairs-2", "b": "corridor-2" },
{ "a": "corridor-2", "b": "classroom-201" }, { "a": "corridor-2", "b": "classroom-201" },
@@ -225,6 +245,7 @@
{ "a": "corridor-2", "b": "computer-lab" }, { "a": "corridor-2", "b": "computer-lab" },
{ "a": "corridor-2", "b": "restroom-2" }, { "a": "corridor-2", "b": "restroom-2" },
{ "a": "yard", "b": "gym-hall" }, { "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" },
], ],
} }
+24 -1
View File
@@ -45,13 +45,36 @@ public static class PlaceClimate
} }
var offset = 8f; var offset = 8f;
ClimatePresetDef? climate = null;
if (school.ClimatePresetId is { } presetId if (school.ClimatePresetId is { } presetId
&& catalog.ClimatePresets.TryGetValue(presetId, out var preset) && catalog.ClimatePresets.TryGetValue(presetId, out var preset)
&& !preset.Abstract) && !preset.Abstract)
{ {
offset = preset.IndoorOffset; 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;
} }
+5
View File
@@ -367,6 +367,11 @@ public sealed class School : IDisposable
foreach (var date in YearlyIntake.DatesBetween(before, after)) foreach (var date in YearlyIntake.DatesBetween(before, after))
{ {
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, CountryId, date, NativeLanguage); Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, CountryId, date, NativeLanguage);
if (Map is not null)
{
Roster = LockerAssigner.Apply(Catalog, Map, Roster);
}
changed = true; changed = true;
} }
@@ -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"));
}
}
@@ -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,
};
}
}
@@ -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<ContentDocument>();
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);
}
}