This commit is contained in:
Leonid Pershin
2026-08-18 23:51:10 +03:00
parent d8f8db6a48
commit 65feda3756
50 changed files with 1485 additions and 224 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ Simulation tunables live under the `Simulation` section of
| `TickRate` | 20 | fixed simulation steps per second | | `TickRate` | 20 | fixed simulation steps per second |
| `MaxSchools` | 6 | how many schools may exist at once | | `MaxSchools` | 6 | how many schools may exist at once |
| `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 | | `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 |
| `DefaultStartDate` | `2012-04-03T06:00:00` | prefilled start of a new school | | `DefaultStartDate` | `2012-03-31T06:00:00` | prefilled start of a new school |
| `SavesDirectory` | `saves` | per-school JSON files | | `SavesDirectory` | `saves` | per-school JSON files |
| `ModsDirectory` | `mods` | pack folders; `core` is required | | `ModsDirectory` | `mods` | pack folders; `core` is required |
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
+147
View File
@@ -0,0 +1,147 @@
# Расписание
Договорённость на срез после штата, не текущий код.
Штат и предметы: [`staffing.md`](staffing.md). Люди: [`people.md`](people.md).
Типы и карта: [`defs.md`](defs.md). Экран: [`near-term.md`](near-term.md).
Часы из учебного плана раскладываются по дням и кабинетам. Школа впервые начинает **жить по
времени**: в 10:15 у 5Б математика в кабинете 204, и это видно на карте.
## Зачем это раньше ходьбы
Расписание закрывает дыру, которая тянется с первого среза: в панели локации пустуют «Персонажи»
и «Сейчас». Заполнить их можно **без системы перемещения** — если урок стоит в расписании, мы
знаем, кто в кабинете и чем занят. Ни поиска пути, ни шагов по коридору: таблица и часы.
Люди по-прежнему не ходят. Они **находятся** там, где их застало расписание.
## Каркас дня
В `core`, дефом: первый урок в 8:30, до семи уроков, урок 45 минут, перемены по 10 с одной
большой на 20. Одна смена.
Длина недели — **в настройках**, а не в контенте: пять дней по умолчанию. Это школьное правило,
а не свойство мира, и его меняют, чтобы посмотреть, что будет.
Каникулы — дефами: осенние, зимние, весенние и лето, диапазонами дат внутри учебного года.
В каникулы расписание не действует, школа стоит.
## Учебный план
Уже есть: `SubjectDef` знает, в каких параллелях преподаётся предмет и сколько часов в неделю.
Расписание ничего не решает про количество — только про расстановку.
## Кто его строит
**Сервер, сам, детерминированно** — из учебного плана, назначений и карты. Игрок не раскладывает
пасьянс на одиннадцать классов; он влияет тем, кого нанял и что назначил.
Но игрок **может править** готовое: передвинуть урок, поменять кабинет.
Отсюда правило, без которого правки будут пропадать: **исправленное игроком закрепляется.**
Перестроение — а оно случается при каждом найме и назначении — сохраняет закреплённые уроки и
пересобирает только остальное. Иначе первый же наём стёр бы всю ручную работу.
Снять закрепление игрок может явно; тогда урок снова участвует в общей раскладке.
## Правила раскладки
Четыре запрета, нарушать которые нельзя:
1. **Учитель не в двух местах.** Один человек — один урок в слоте.
2. **Класс не в двух местах.** Один класс — один урок в слоте.
3. **Кабинет не занят дважды.** Два класса в одной комнате одновременно невозможны.
4. **Класс влезает в кабинет.** Учеников не больше, чем мест.
Урок идёт **не обязательно в своём кабинете**. У предмета может быть требование к типу помещения:
физкультура — в спортзале, информатика — в компьютерном классе. Без требования урок идёт в
кабинете, закреплённом за классом.
Это и делает карту нужной: один спортзал на одиннадцать классов — узкое место, которое видно в
расписании, а не в отчёте.
## Некому вести — урока нет
Предмет без учителя не ставится вовсе. В расписании класса на этом месте пусто, а в разделе
«Управление» предмет числится непокрытым.
Так наём получает прямое последствие: нанял человека, назначил ему предмет — в расписании
появились уроки. Это единственная обратная связь среза, и она должна быть мгновенной.
То же самое, если часов больше, чем вытягивают нанятые: лишние часы просто не встают.
## Что видит игрок
**В дереве карты** — что происходит в комнате прямо сейчас, скобками рядом с названием:
```
Этаж 2
Кабинет 204 (Математика · 5Б)
Кабинет 205 (Физика · 9А)
Кабинет 206
```
Так классы не сливаются в одинаковые строчки: комната подписана тем, что в ней идёт. Пусто —
значит свободна.
**В панели локации** — «Сейчас» и «Персонажи» наконец не пустуют: урок, класс, учитель.
**В «Управлении»** — расписание класса: сетка «день × урок» на одну параллель.
**В карточке учителя** — его личное расписание: где он и когда. Это же карточка, что и в «Людях»,
так что расписание видно всюду, где человек открывается.
Общей сетки на всю школу нет: одиннадцать классов на шесть дней не читаются с экрана, а нужны
они только тому, кто раскладывает вручную, — а раскладывает сервер.
## Где это живёт
Планировщик — **отдельная библиотека**, как `HSchool.People`: зависит от `HSchool.Content`, не
знает ни Arch, ни ASP.NET, отдаёт простую таблицу. Причина та же, что и с генератором людей: то,
что строится по детерминированным правилам, проверяется только тестами — «этот штат и эта карта
дают вот такую раскладку», — а тест не должен поднимать хост.
Расписание сохраняется вместе с составом: оно дешевле пересчитывается, чем восстанавливается, но
ручные правки восстановить нельзя. Пишется по тем же правилам, что и ростер — при изменении, а не
по таймеру.
## Советы, которые стоит принять сразу
- Не давать игроку раскладывать всё вручную. Автоматика с правками — это управление; пустая сетка
на 66 ячеек — это работа завуча.
- Не пересобирать расписание на каждый тик. Оно меняется от найма и назначения, а не от времени.
- Не хранить «кто где сейчас» отдельным состоянием. Это вычисляется из расписания и часов; вторая
копия немедленно разойдётся с первой.
- Не ставить урок, который некому вести. Дыра в расписании честнее, чем урок без учителя.
- Требование к типу помещения — у предмета, а не у комнаты. Комната не знает, что в ней физкультура.
## Заведомо не сейчас
- Перемещение по карте: люди находятся в комнате, но не идут в неё.
- Оценки, посещаемость, домашние задания, успеваемость.
- Замены заболевшего учителя.
- Вторая смена, деление класса на подгруппы (языки, труд).
- Четверти, триместры, экзамены.
- Кружки, продлёнка, всё вне уроков.
- Предпочтения учителей: «не ставьте мне первым уроком».
## Зафиксировано этим разговором
| Тема | Решение |
| --- | --- |
| Кто строит | Сервер, детерминированно, из плана и назначений |
| Правки игрока | Можно; исправленное закрепляется и переживает перестроение |
| Каркас дня | `core`: 8:30, до 7 уроков, 45 минут, перемены 10 и одна 20; одна смена |
| Длина недели | В настройках, пять дней по умолчанию |
| Каникулы | Дефами, диапазонами дат; в каникулы расписания нет |
| Учебный план | Часы из `SubjectDef`, расписание их только расставляет |
| Запреты | Учитель, класс и кабинет — не более одного урока в слоте; класс влезает в кабинет |
| Место урока | Кабинет класса, если у предмета нет требования к типу помещения |
| Нет учителя | Урок не ставится; предмет числится непокрытым |
| Карта | В дереве рядом с комнатой — что и у кого там сейчас |
| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания |
| Расписание класса | В разделе «Управление», сетка «день × урок» |
| Расписание учителя | В его карточке, там же где навыки и семья |
| Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную |
| Где живёт код | Отдельная библиотека рядом с `HSchool.People` |
| Люди и место | Находятся по расписанию; ходьбы нет |
+226
View File
@@ -0,0 +1,226 @@
# Наём, предметы и бюджет
Договорённость на следующий срез, не текущий код.
Люди: [`people.md`](people.md). Типы и карта: [`defs.md`](defs.md).
Экран игрока: [`near-term.md`](near-term.md). Проекты: [`projects.md`](projects.md).
Первый срез, где игрок **что-то решает**. До сих пор школа собиралась сама: генератор закрывал
все должности, игрок смотрел. Теперь школа открывается пустой — без единого учителя, — и её
надо укомплектовать: нанять людей из пула соискателей, раздать им предметы и уложиться в
выделенные деньги.
Уроков по-прежнему нет. Предмет — это то, что учитель **числится** вести, а не то, что происходит
в кабинете. Расписание, звонки и оценки — следующий разговор.
## Что меняется в уже принятом
Три решения прошлых срезов приходится поправить. Это не мелочь, поэтому названы отдельно.
| Было | Стало | Почему |
| --- | --- | --- |
| Генератор закрывает все должности | Школа стартует без сотрудников | Иначе игроку нечего решать |
| Кабинет — таблица именованных слотов | Кабинет — число мест | Никто не будет выбирать, какой стул у доски |
| `PETeacher` — отдельная должность | Должность одна, физкультура — предмет | Иначе на каждый предмет заводится должность |
## Мебель и вместимость
Вместимость по-прежнему **физическая**: в классе не может сидеть больше учеников, чем стоит парт.
Это не меняется — от неё зависит весь ростер.
Меняется то, как эта мебель задаётся. У кабинета исчезает таблица именованных слотов — «доска»,
«стол учителя», «стул учителя», «парты» — и остаётся одно число: сколько парт в кабинете.
Выбирать между стулом и креслом у доски игрок не будет никогда, а таблица из четырёх строк на
каждый из одиннадцати кабинетов делает редактор нечитаемым.
Именованные слоты **остаются** там, где обстановка действительно разная: столовая, медкабинет,
кабинет директора, библиотека. Там выбор осмыслен — сколько столов, какая кушетка.
Правило простое: **слоты — там, где есть из чего выбирать; число — там, где важно только сколько.**
## Предметы
Новый вид def — `SubjectDef`. Не навык и не работа: навык — это то, что растёт у ребёнка, работа —
то, чем занят человек в комнате, а предмет — то, что стоит в дипломе и в нагрузке.
`SubjectDef` описывает:
- **какие навыки развивает и насколько** — список пар «навык + доля»;
- **в каких параллелях преподаётся**;
- **сколько часов в неделю** в каждой из них — учебный план.
Часы здесь не задел на расписание, а рабочая величина уже этого среза: из них считается нагрузка
учителя, а из нагрузки — зарплата. Расписание ([`schedule.md`](schedule.md)) только расставит эти
часы по дням; сколько их — решает учебный план.
Так «начальные классы» получаются естественно: один предмет, который развивает всё понемногу и
преподаётся с первой по четвёртую параллель. Дальше идут предметники: математика с пятой по
одиннадцатую, развивает один навык, но сильно.
```jsonc
{
"defName": "PrimarySchool",
"grades": { "min": 1, "max": 4 },
"hoursPerWeek": 20,
"skills": [
{ "skill": "Mathematics", "share": 0.3 },
{ "skill": "RussianLanguage", "share": 0.3 },
{ "skill": "Literature", "share": 0.2 },
{ "skill": "Biology", "share": 0.2 },
],
}
```
Имена предметов и навыков **намеренно совпадают** там, где это одно и то же слово: `SubjectDef`
`Mathematics` и `SkillDef` `Mathematics` — разные def'ы разных видов, но подпись у них одна, потому
что локали ключуются по имени. Это то, что нужно: игрок не должен видеть «Математика (предмет)».
Доли не обязаны давать в сумме единицу — это не проценты, а вес влияния. Ни на что в этом срезе
они не влияют; они здесь потому, что предмет без ответа на вопрос «что он развивает» не проверишь,
а дописывать поле позже — значит переписывать весь ванильный контент.
## Учитель
Должность одна — `Teacher`. Предмет не в должности, а в **назначении**: у нанятого учителя
один предмет или несколько.
Отсюда следует важное: **потребность в учителях больше не берётся из комнат.** Раньше кабинет
открывал вакансию «учитель», спортзал — «физрук». Теперь кабинет не открывает ничего: он лишь
место, где идёт урок. Школе нужны не «люди в комнатах», а **покрытие предметов по параллелям**
на каждый предмет, преподаваемый в существующих параллелях, нужен хотя бы один учитель.
Нетренерские должности — директор, секретарь, медсестра, библиотекарь, повар — по-прежнему рождаются
из комнат, как описано в [`defs.md`](defs.md). Их игрок тоже нанимает, но предметов им не назначают.
## Соискатели
Пул соискателей — **общий** список на школу, не «по трое на вакансию».
Соискатель — **полноценный человек** со всеми слоями: тело, навыки, черты, семья. Иначе выбирать
не из чего: весь смысл в том, чтобы сравнить «математика 78, усидчивый» с «математика 41,
вспыльчивый».
Соискателем **может оказаться родитель ученика** — и это не частный случай, а естественное
следствие уже принятого: человек может быть в двух ролях сразу. Нанятый родитель становится
работником, оставаясь родителем, и никакой второй сущности для него не заводится.
Пул **обновляется раз в неделю и не целиком**. Часть людей уходит — нашли работу в другом месте, —
часть остаётся искать дальше, приходят новые. Полная замена раз в неделю превращала бы выбор в
лотерею: понравившийся кандидат исчезал бы, пока игрок думает.
Не нанятый и не ушедший остаётся тем же человеком: с тем же именем, теми же навыками и тем же
запросом по зарплате. Обновление детерминировано от сида школы и номера недели.
## Деньги
**Баланса нет.** Сверху выделяют фиксированную сумму на зарплаты в месяц — не на счёт, а как
предел. Ничего не копится, сэкономленное не переносится.
Значит и правило одно: **месячный фонд оплаты не может превысить выделенную сумму.** Выделили
10 000, наняли одного за 3 000 и второго за 7 000 — всё, больше никого. Не «ушли в минус», не
«долг», а просто нельзя.
### Сколько просит человек
Соискатель называет **цену часа**. Она складывается из двух вещей:
- **навыки** — сильный предметник стоит дороже слабого;
- **черты** — самоуверенный просит больше, зажатый меньше, при тех же навыках.
Черта, влияющая на запрос, — это поле у `TraitDef`, а не список фамилий в коде. Мод, добавивший
«скромный», сразу получает более дешёвого кандидата.
### Сколько он в итоге стоит
Месячная зарплата — **цена часа × недельная нагрузка**. Нагрузка не выдумывается: она следует из
учебного плана. Назначили человеку математику — он забирает все часы математики во всех
параллелях, где она преподаётся, и это может оказаться много.
Отсюда два ограничения вместо одного:
- **Деньги.** Фонд оплаты не может превысить выделенную сумму.
- **Человек.** Никто не может вести больше `MaxWeeklyHours` часов в неделю. Если часов предмета
больше, чем вытянет один, нужен второй учитель того же предмета — часы делятся между ними.
Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один
фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой
с одним предметом съест фонд.
Цифры — шкала цены часа, влияние черт, предел часов — живут в настройках и в `core`, не в коде.
## Старт в выходной
Школа создаётся пустой, и первое, что делает игрок, — нанимает людей. Делать это под тикающие
часы учебного дня незачем, поэтому дата по умолчанию переезжает с вторника **3 апреля 2012** на
субботу **31 марта 2012**: у игрока есть выходные, чтобы собрать штат до того, как в понедельник
придут дети.
Учебный год при этом тот же, ростер не меняется.
## Экран
Не третья вкладка в панели и не модальное окно. Под часами появляется **два верхних раздела**:
| Раздел | Что в нём |
| --- | --- |
| **Обзор** | Всё, что есть сейчас: карта / люди слева, просмотр выбранного справа |
| **Управление** | Деньги, соискатели, штат и назначение предметов |
Верхние разделы, а не вкладки внутри панели, потому что управление — это другой режим работы, а
не ещё один список. Часы и дата остаются на месте в обоих: наём идёт по игровому времени, пул
обновляется по неделям, и прятать календарь на время найма нельзя.
Внутри «Управления» та же схема, что и в «Обзоре»: список слева, карточка справа. Карточка
соискателя — та же карточка человека, что и в «Людях»; он и есть человек, просто пока не ваш.
Сверху — одна строка про деньги: выделено, расписано, свободно. Ниже — непокрытые предметы,
соискатели и нанятый штат.
## Советы, которые стоит принять сразу
- Не заводить должность на каждый предмет. Иначе `PositionDef` превращается в справочник
специальностей, а назначение предмета становится увольнением и повторным наймом.
- Не делать баланс, пока сверху приходит фиксированная сумма. Баланс — это уже экономика:
накопления, долги, проценты. Здесь нужен предел, а предел — одно число.
- Не обновлять пул целиком. Кандидат, исчезнувший, пока игрок его разглядывал, читается как баг.
- Не выкидывать не нанятых людей в ростер школы. Соискатель — не сотрудник и не житель школы;
в ростер он попадает только при найме.
- Проверять предел в момент действия, а не в конце месяца. «Нельзя нанять» игрок понимает,
«в конце месяца что-то случилось» — нет.
## Заведомо не сейчас
- Само расписание: когда именно идут эти часы. Следующий срез, [`schedule.md`](schedule.md).
Здесь предмет — нагрузка в часах, а не событие в кабинете.
- Уроки, звонки, оценки, посещаемость.
- Классное руководство: класс закреплён за кабинетом, но не за учителем.
- Увольнение и уход по собственному желанию.
- Переговоры о зарплате, повышения, стаж.
- Любые траты, кроме зарплат: ремонт, закупки, коммуналка.
- Накопление денег, долги, штрафы.
- Требования к диплому: любой нанятый может вести любой предмет, вопрос только в его навыке.
## Зафиксировано этим разговором
| Тема | Решение |
| --- | --- |
| Старт школы | Без сотрудников вообще; игрок нанимает всех |
| Стартовая дата | Суббота 31 марта 2012 — выходные на сборку штата |
| Вместимость класса | По-прежнему физическая: не больше учеников, чем парт |
| Кабинет | Одно число мест вместо таблицы именованных слотов |
| Слоты | Остаются там, где обстановка разная: столовая, медкабинет, кабинет директора |
| Должность учителя | Одна, `Teacher`; `PETeacher` упраздняется |
| Предмет | Новый `SubjectDef`: какие навыки развивает и в каких параллелях |
| Начальные классы | Предмет, развивающий всё понемногу, параллели 1–4 |
| Назначение | У учителя один предмет или несколько |
| Потребность в учителях | Покрытие предметов по параллелям, а не вакансии в комнатах |
| Прочие должности | По-прежнему из комнат; игрок нанимает и их |
| Соискатель | Полноценный человек; может быть родителем ученика |
| Пул | Общий, обновляется раз в неделю частично, детерминированно |
| Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел |
| Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) |
| Зарплата | Цена часа × недельная нагрузка из учебного плана |
| Предел нагрузки | `MaxWeeklyHours` на человека; сверх — нужен второй учитель предмета |
| Бюджет | Фиксированная сумма в месяц из настроек; баланса нет |
| Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия |
| Экран | Два верхних раздела под часами: «Обзор» и «Управление» |
| Внутри управления | Список слева, карточка человека справа — та же, что в «Людях» |
+39
View File
@@ -0,0 +1,39 @@
# Фаза 10. Предметы и мебель
## Зависимости
- [Фаза 5](05-people-defs.md)
- [Фаза 7](07-people-in-school.md)
## Зачем
Каталог учится описывать предметы, кабинет перестаёт быть таблицей слотов, а должность учителя
становится одной на всех. Людей это ещё не двигает: школа собирается как прежде.
## Задачи
- [x] `SubjectDef` в `HSchool.Content`: развиваемые навыки с долями, диапазон параллелей;
ссылка на неизвестный навык — ошибка загрузки
- [x] Ванильные предметы в `core`: начальные классы (1–4, всё понемногу) и предметники по
существующим навыкам, включая физкультуру
- [x] `PositionDef PETeacher` упраздняется; `Classroom`, `ComputerLab` и `GymHall` больше не
открывают учительскую должность
- [x] Кабинет-`Homeroom` описывается числом мест, а не таблицей именованных слотов; вместимость
по-прежнему считается по мебели
- [x] Слоты остаются у столовой, медкабинета, кабинета директора и библиотеки
- [x] Редактор карты показывает у кабинета одно поле «мест», у прочих комнат — прежние слоты
- [x] Ванильная карта переписана под новую форму, вместимость параллелей не изменилась
- [x] `GET /api/catalog` отдаёт предметы — они понадобятся форме назначения
- [x] Стартовая дата по умолчанию — суббота 31 марта 2012
- [x] Снимок карты и `docs/protocol.md` правятся в том же коммите, если меняется раскладка узла
## Критерий готовности
- Каталог `core` грузится, предметы проходят валидацию, тест на полноту локалей зелёный
- Школа на ванильной карте даёт те же одиннадцать классов и ту же вместимость, что и до фазы
- Редактор карты не показывает у кабинета ни доски, ни стула учителя
- `dotnet test` и клиентские `npm test` / `build` проходят
## Стоп
Не трогать наём. Генератор пока закрывает должности сам — школа опустеет в следующей фазе.
+44
View File
@@ -0,0 +1,44 @@
# Фаза 11. Пустая школа и пул соискателей
## Зависимости
- [Фаза 10](10-subjects.md)
- [Фаза 6](06-people-generator.md)
## Зачем
Школа открывается без единого сотрудника, а рядом появляется пул людей, готовых наняться.
Нанимать пока нечем — это фаза про данные.
## Задачи
- [ ] `RosterGenerator` больше не закрывает должности: новая школа получает учеников, родителей
и ноль сотрудников
- [ ] Пул соискателей в `HSchool.People`: список полноценных `Person`, детерминированный от сида
школы и номера недели
- [ ] Соискателем может стать родитель ученика — тот же человек, без второй сущности
- [ ] Базовый запрос по зарплате считается из навыков соискателя; шкала — в данных, не в коде
- [ ] Еженедельное обновление: часть уходит, часть остаётся с теми же именами и запросом,
приходят новые. Полная замена запрещена
- [ ] Пул живёт в `saves/{id}.people.json` рядом с ростером и пишется по тем же правилам —
при изменении состава, не по таймеру
- [ ] Воркер публикует пул вместе со снимком ростера
## Тесты, без которых фаза не закрыта
- [ ] Тот же сид и та же неделя — тот же пул
- [ ] После обновления часть людей та же самая, с теми же навыками и тем же запросом
- [ ] Пул не пустеет и не растёт бесконечно за пятьдесят недель
- [ ] Запрос по зарплате у сильного кандидата выше, чем у слабого
- [ ] Новая школа не имеет ни одного сотрудника, но все ученические места заняты
- [ ] Соискатель не попадает в ростер школы, пока не нанят
## Критерий готовности
- Создать школу — в списке людей есть ученики и родители, работников нет
- Перезапуск сервера поднимает тот же пул
- `dotnet test` проходит
## Стоп
Ни найма, ни денег, ни назначения предметов. Пул только существует и обновляется.
+42
View File
@@ -0,0 +1,42 @@
# Фаза 12. Наём, предметы и предел бюджета
## Зависимости
- [Фаза 11](11-applicants.md)
## Зачем
Игрок нанимает людей и раздаёт предметы, а выделенная сумма ограничивает, сколько он может себе
позволить. Экрана ещё нет — всё через API.
## Задачи
- [ ] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
- [ ] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого
- [ ] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель
остаётся родителем — новой сущности не заводится
- [ ] Назначение предмета нанятому и снятие предмета
- [ ] Предел проверяется **в момент действия**: наём или назначение, выводящее фонд за
выделенную сумму, отклоняется с внятным кодом ошибки, а не откладывается до конца месяца
- [ ] Покрытие предметов: какие предметы преподаются в существующих параллелях и не имеют ни
одного учителя
- [ ] `GET /api/schools/{id}/staffing` — деньги, покрытие, пул и штат; читает опубликованный
снимок, воркер не трогает
- [ ] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete
- [ ] Состав пишется на диск при изменении, снимок публикуется заново
- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики
## Критерий готовности
- Нанять двоих на 3 000 и 7 000 при выделенных 10 000 — третий наём отклонён
- Снять предмет — фонд уменьшился, наём снова возможен
- Назначение, не влезающее в предел, отклонено, и в ответе видно почему
- Перезапуск сервера поднимает школу с тем же штатом и теми же назначениями
- Тесты API: наём, повторный наём того же человека, отклонение по пределу, неизвестная школа,
неизвестный соискатель
- `dotnet test` проходит
## Стоп
Не увольнять. Не платить зарплату по месяцам — предел проверяется, деньги не движутся.
Не расписание.
+36
View File
@@ -0,0 +1,36 @@
# Фаза 13. Раздел «Управление»
## Зависимости
- [Фаза 12](12-hiring-budget.md)
- [Фаза 8](08-people-browser.md)
## Зачем
Игрок собирает школу руками: видит деньги, перебирает соискателей, нанимает и раздаёт предметы.
## Задачи
- [ ] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что
есть сейчас; часы и дата видны в обоих
- [ ] Строка денег: выделено, расписано, свободно
- [ ] Список непокрытых предметов — что школа не может преподавать
- [ ] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку
человека справа — ту же, что в «Людях»
- [ ] Кнопка найма; отказ по пределу показывается текстом, а не молчанием
- [ ] Список штата: кто нанят, какие предметы ведёт, сколько стоит
- [ ] Назначение и снятие предмета с карточки нанятого
- [ ] Строки через `t(...)`, обе локали
## Критерий готовности
- Создать школу, открыть «Управление», нанять учителя, назначить ему математику
- Дойти до предела — попытка нанять ещё одного объясняет, что денег нет
- Снять предмет и убедиться, что свободных денег стало больше
- Перейти в «Обзор» → «Люди» и увидеть нанятого в общем списке с ролью работника
- Нанять родителя ученика и увидеть у него обе роли на одной карточке
- `dotnet test` и клиентские `npm test` / `build` проходят
## Стоп
Не увольнять из интерфейса. Не показывать расписание и нагрузку по часам — только предметы.
+33
View File
@@ -0,0 +1,33 @@
# Фаза 14. Каркас дня и каникулы
## Зависимости
- [Фаза 10](10-subjects.md)
## Зачем
Школа получает учебный календарь: во сколько звонок, сколько уроков, какие дни рабочие и когда
каникулы. Расписания ещё нет — есть время, в которое его можно будет положить.
## Задачи
- [ ] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен
и какая из них большая
- [ ] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию
- [ ] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года
- [ ] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому
времени, выходным и каникулам
- [ ] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в
компьютерном классе; без требования — кабинет класса
- [ ] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания
## Критерий готовности
- В субботу, в каникулы и в 23:00 запрос слота отвечает «занятий нет»
- В 10:20 буднего учебного дня отвечает конкретным номером урока
- Смена длины недели в настройках делает субботу рабочей
- `dotnet test` проходит
## Стоп
Ни одного урока не ставится. Ни планировщика, ни экрана.
+41
View File
@@ -0,0 +1,41 @@
# Фаза 15. Планировщик
## Зависимости
- [Фаза 14](14-school-calendar.md)
- [Фаза 12](12-hiring-budget.md)
## Зачем
Из учебного плана, назначений и карты получается таблица уроков. Библиотека проверяется тестами,
без мира и без хоста, как генератор людей.
## Задачи
- [ ] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
ASP.NET; отдаёт простую таблицу уроков
- [ ] Раскладка часов учебного плана по слотам недели для каждого класса
- [ ] Кабинет урока: требование предмета, иначе кабинет класса
- [ ] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
- [ ] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
- [ ] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
раскладывается вокруг них
- [ ] Детерминированность: те же штат, назначения и карта дают ту же таблицу
## Тесты, без которых фаза не закрыта
- [ ] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
- [ ] Учитель с двумя предметами не стоит в двух местах в одном слоте
- [ ] Класс на 16 не попадает в кабинет на 12
- [ ] Предмет без учителя даёт дыру и запись в непокрытых
- [ ] Закреплённый урок остаётся на месте после найма ещё одного учителя
- [ ] Те же входы — та же таблица
## Критерий готовности
- Тесты библиотеки зелёные
- В проекте нет упоминаний сокетов, ECS и `DateTime.Now`
## Стоп
Не трогать сервер, сейв и клиент. Таблица пока никуда не уезжает.
+38
View File
@@ -0,0 +1,38 @@
# Фаза 16. Расписание в школе
## Зависимости
- [Фаза 15](15-timetable-planner.md)
## Зачем
Расписание живёт в школе, переживает перезапуск и отвечает на вопрос «кто где сейчас». Экрана
ещё нет.
## Задачи
- [ ] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику**
- [ ] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые
игроком уроки сохраняются вместе с ним
- [ ] Воркер публикует расписание снимком, как ростер
- [ ] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием
- [ ] Команда закрепления и снятия закрепления урока — через мейлбокс воркера
- [ ] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает
опубликованный снимок
- [ ] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md`
правятся тем же коммитом, версия бумпится
- [ ] Панель локации получает данные для «Сейчас» и «Персонажей»
## Критерий готовности
- Нанять учителя математики — в расписании появились уроки математики
- Снять назначение — уроки исчезли, предмет стал непокрытым
- Передвинуть урок, нанять ещё одного человека — передвинутый остался на месте
- Перемотать время на ×4 через звонок — «кто где сейчас» меняется вместе с уроком
- В каникулы и в выходной никто нигде не находится
- Перезапуск сервера поднимает то же расписание с теми же закреплениями
- `dotnet test` проходит
## Стоп
Не рисовать расписание на экране. Не перемещать людей по карте.
+34
View File
@@ -0,0 +1,34 @@
# Фаза 17. Расписание на экране
## Зависимости
- [Фаза 16](16-timetable-in-school.md)
- [Фаза 13](13-management-tab.md)
## Зачем
Школа перестаёт быть списком имён: видно, что происходит в комнатах прямо сейчас и как выглядит
неделя класса и учителя.
## Задачи
- [ ] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`.
Свободная комната остаётся без скобок
- [ ] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
- [ ] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
- [ ] В карточке человека — его личное расписание, рядом с навыками и семьёй
- [ ] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
- [ ] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
- [ ] Строки через `t(...)`, обе локали
## Критерий готовности
- Открыть школу в учебное время и увидеть в дереве занятые кабинеты, а в каникулы — пустые
- Открыть расписание 5Б, найти окно на месте непокрытого предмета
- Открыть учителя и увидеть те же уроки с его стороны
- Передвинуть урок и убедиться, что он не разъехался после найма
- `dotnet test` и клиентские `npm test` / `build` проходят
## Стоп
Не общая сетка на всю школу. Не оценки и не посещаемость. Не анимация перемещений.
+32
View File
@@ -36,3 +36,35 @@
| [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | | [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` |
| [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой | | [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой |
| [9. Годовой набор](09-yearly-intake.md) | ✅ | Первое сентября: переход, выпуск, набор | | [9. Годовой набор](09-yearly-intake.md) | ✅ | Первое сентября: переход, выпуск, набор |
## Срез 3. Штат
Дизайн: [`../design/staffing.md`](../design/staffing.md).
Первый срез, где игрок принимает решения. Школа открывается пустой: надо нанять людей из пула
соискателей, раздать им предметы и уложиться в выделенную на месяц сумму. Уроков по-прежнему нет —
предмет это запись в нагрузке, а не событие в кабинете.
Фазы идут строго по порядку: каждая опирается на данные предыдущей.
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
| [11. Пустая школа и пул](11-applicants.md) | ⬜ | Школа без сотрудников, соискатели с запросом по зарплате |
| [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты |
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
## Срез 4. Расписание
Дизайн: [`../design/schedule.md`](../design/schedule.md).
Часы учебного плана раскладываются по дням и кабинетам. Школа начинает жить по времени: видно,
что идёт в комнате прямо сейчас. Люди при этом по-прежнему **не ходят** — они находятся там, где
их застало расписание.
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [14. Каркас дня и каникулы](14-school-calendar.md) | ⬜ | Звонки, длина недели, каникулы |
| [15. Планировщик](15-timetable-planner.md) | ⬜ | Раскладка часов по слотам, четыре запрета |
| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» |
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
+15 -6
View File
@@ -35,10 +35,10 @@ Everything the main menu needs in one request.
```json ```json
{ {
"maxSchools": 6, "maxSchools": 6,
"defaultStartDate": "2012-04-03T06:00:00Z", "defaultStartDate": "2012-03-31T06:00:00Z",
"gameMinutesPerRealSecond": 5, "gameMinutesPerRealSecond": 5,
"schools": [ "schools": [
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-04-03T07:35:00Z", "running": false, "speedIndex": 1 } { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 }
] ]
} }
``` ```
@@ -66,13 +66,22 @@ Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/de
`core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated `core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated
list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`.
Room slots include a `count` (how many of the default thing the def places). Things carry Room defs that are homerooms carry `homeroom`, `seatThing` and `defaultSeats` instead of a
`pupilSlots` — how many pupils that thing hosts for lessons. The create editor copies both onto slot table. A map classroom stores `seats` — how many of that thing occupy the room. Capacity is
the map instance; the snapshot's per-node pupil-slot total is computed on the server. still `ThingDef.pupilSlots × seats`. Named slots remain on rooms whose furnishing actually
varies (cafeteria, medical office, principal's office, library). The create editor shows a
single seats field on a homeroom and the old slot rows on everything else.
Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot
total is computed on the server.
`nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one; `nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one;
it is independent of the UI language. it is independent of the UI language.
`subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
`hoursPerWeek`, and `skills` with shares). The assignment form in a later phase reads it; the
create editor does not.
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is `lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
Russian. Russian.
@@ -83,7 +92,7 @@ Body:
```json ```json
{ {
"name": "Гимназия №14", "name": "Гимназия №14",
"startDate": "2012-04-03T06:00:00Z", "startDate": "2012-03-31T06:00:00Z",
"modIds": [], "modIds": [],
"map": null, "map": null,
"nameSetId": "Slavic" "nameSetId": "Slavic"
@@ -9,8 +9,8 @@ import {
toDateAndTimeInputs, toDateAndTimeInputs,
} from './gameTime.ts'; } from './gameTime.ts';
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday. // The default start of a new school: 31 March 2012, 06:00 — a Saturday.
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0)); const START = new Date(Date.UTC(2012, 2, 31, 6, 0, 0));
const initial = getLocale(); const initial = getLocale();
beforeEach(() => setLocale('ru')); beforeEach(() => setLocale('ru'));
@@ -22,25 +22,25 @@ describe('game time formatting', () => {
}); });
it('shows the weekday of the game date', () => { it('shows the weekday of the game date', () => {
expect(formatGameWeekday(START)).toBe('вторник'); expect(formatGameWeekday(START)).toBe('суббота');
}); });
it('shows the full date', () => { it('shows the full date', () => {
expect(formatGameDate(START)).toContain('2012'); expect(formatGameDate(START)).toContain('2012');
expect(formatGameDate(START)).toContain('апреля'); expect(formatGameDate(START)).toContain('марта');
}); });
it('shows a compact date and time for the school cards', () => { it('shows a compact date and time for the school cards', () => {
expect(formatGameDateTime(START)).toContain('03.04.2012'); expect(formatGameDateTime(START)).toContain('31.03.2012');
expect(formatGameDateTime(START)).toContain('06:00'); expect(formatGameDateTime(START)).toContain('06:00');
}); });
it('formats the same instant in English when the locale is en', () => { it('formats the same instant in English when the locale is en', () => {
setLocale('en'); setLocale('en');
expect(formatGameWeekday(START)).toBe('Tuesday'); expect(formatGameWeekday(START)).toBe('Saturday');
expect(formatGameDate(START)).toContain('April'); expect(formatGameDate(START)).toContain('March');
expect(formatGameDateTime(START)).toContain('03/04/2012'); expect(formatGameDateTime(START)).toContain('31/03/2012');
expect(formatGameTimeOfDay(START)).toBe('06:00'); expect(formatGameTimeOfDay(START)).toBe('06:00');
}); });
@@ -55,7 +55,7 @@ describe('game time formatting', () => {
describe('date inputs', () => { describe('date inputs', () => {
it('splits an instant into the date and time input values', () => { it('splits an instant into the date and time input values', () => {
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-04-03', time: '06:00' }); expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-03-31', time: '06:00' });
}); });
it('rebuilds the same instant from those values', () => { it('rebuilds the same instant from those values', () => {
+2
View File
@@ -65,6 +65,7 @@ const ru = {
removeRoom: 'Удалить комнату', removeRoom: 'Удалить комнату',
slotEmpty: '— пусто —', slotEmpty: '— пусто —',
slotCount: 'Количество', slotCount: 'Количество',
editorSeats: 'Мест',
backToMenu: '← В главное меню', backToMenu: '← В главное меню',
pause: 'Пауза', pause: 'Пауза',
@@ -193,6 +194,7 @@ const en: Messages = {
removeRoom: 'Remove room', removeRoom: 'Remove room',
slotEmpty: '— empty —', slotEmpty: '— empty —',
slotCount: 'Count', slotCount: 'Count',
editorSeats: 'Seats',
backToMenu: '← Main menu', backToMenu: '← Main menu',
pause: 'Pause', pause: 'Pause',
+19
View File
@@ -85,10 +85,27 @@ export interface RoomSlotInfo {
export interface RoomInfo { export interface RoomInfo {
readonly defName: string; readonly defName: string;
readonly label: string; readonly label: string;
readonly homeroom: boolean;
readonly seatThing?: string | null;
readonly defaultSeats: number;
readonly slots: readonly RoomSlotInfo[]; readonly slots: readonly RoomSlotInfo[];
readonly positions: readonly string[]; readonly positions: readonly string[];
} }
export interface SubjectSkillShare {
readonly skill: string;
readonly share: number;
}
export interface SubjectInfo {
readonly defName: string;
readonly label: string;
readonly gradeMin: number;
readonly gradeMax: number;
readonly hoursPerWeek: number;
readonly skills: readonly SubjectSkillShare[];
}
export interface MapLayout { export interface MapLayout {
territory: { id: string; def: string }; territory: { id: string; def: string };
buildings: { id: string; def: string }[]; buildings: { id: string; def: string }[];
@@ -99,6 +116,7 @@ export interface MapLayout {
building: string; building: string;
floor: string; floor: string;
label?: string; label?: string;
seats?: number;
slots?: { key: string; thing: string; count?: number }[]; slots?: { key: string; thing: string; count?: number }[];
}[]; }[];
links: { a: string; b: string }[]; links: { a: string; b: string }[];
@@ -112,6 +130,7 @@ export interface CatalogResponse {
readonly things: readonly DefInfo[]; readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout; readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[]; readonly nameSets: readonly DefInfo[];
readonly subjects: readonly SubjectInfo[];
} }
export async function fetchMods(): Promise<readonly ModInfo[]> { export async function fetchMods(): Promise<readonly ModInfo[]> {
+116 -5
View File
@@ -532,8 +532,9 @@ body {
} }
.people__card-name { .people__card-name {
margin: 0 0 4px; margin: 0 0 2px;
font-size: 16px; font-size: 15px;
font-weight: 600;
} }
.people__card-meta { .people__card-meta {
@@ -542,12 +543,122 @@ body {
} }
.people__section { .people__section {
margin-top: 12px; margin-top: 10px;
} }
.people__stats { .people__section-title {
margin: 0 0 3px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/*
* Name on the left, value on the right, as many columns as the panel is wide. As a bulleted list
* fourteen skills ran the card past the fold with three quarters of the width empty.
*/
.people__pairs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0 16px;
margin: 0; margin: 0;
padding-left: 18px; font-size: 12px;
}
.people__pair {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 1px 0;
border-bottom: 1px solid rgba(42, 50, 66, 0.5);
}
.people__pair dt {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people__pair dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.people__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.people__tag {
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
}
.people__needs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 6px 16px;
}
.people__need {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 8px;
font-size: 12px;
}
.people__need-label {
color: var(--text-muted);
}
.people__need-value {
font-variant-numeric: tabular-nums;
}
.people__need-track {
grid-column: 1 / -1;
height: 4px;
margin-top: 2px;
border-radius: 999px;
background: var(--surface);
overflow: hidden;
}
.people__need-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
/* An empty need is the one worth spotting without reading the number. */
.people__need-fill--low {
background: var(--danger);
}
.people__rel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
font-size: 12px;
}
.people__rel-title {
color: var(--text-muted);
}
.people__rel-list {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
} }
.people__link { .people__link {
+67 -39
View File
@@ -103,52 +103,78 @@ export function mapEditor(options: MapEditorOptions): {
} }
const rows: HTMLElement[] = []; const rows: HTMLElement[] = [];
for (const slot of roomDef.slots) { if (roomDef.homeroom) {
const select = el('select', { class: 'input' }); const seatsInput = el('input', { class: 'input input--count', type: 'number' });
const empty = el('option', { text: t('slotEmpty') }); seatsInput.min = '1';
empty.value = ''; seatsInput.max = '255';
select.append(empty); seatsInput.step = '1';
for (const thing of catalog.things) { seatsInput.value = String(room.seats ?? roomDef.defaultSeats ?? 16);
const option = el('option', { text: thing.label }); seatsInput.title = t('editorSeats');
option.value = thing.defName; seatsInput.addEventListener('change', () => {
select.append(option); const parsed = Number.parseInt(seatsInput.value, 10);
} const seats = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (roomDef.defaultSeats || 16);
seatsInput.value = String(seats);
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key); room.seats = seats;
select.value = fill?.thing ?? ''; room.slots = [];
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
emit(); emit();
}; });
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
rows.push( rows.push(
el( el(
'label', 'label',
{ class: 'field__row' }, { class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }), el('span', { class: 'field__label', text: t('editorSeats') }),
select, seatsInput,
countInput,
), ),
); );
} else {
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
emit();
};
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
countInput,
),
);
}
} }
return section( return section(
@@ -251,7 +277,9 @@ export function mapEditor(options: MapEditorOptions): {
def: def.defName, def: def.defName,
building: building.id, building: building.id,
floor: floor.id, floor: floor.id,
slots: defaultSlots(def), ...(def.homeroom
? { seats: def.defaultSeats || 16 }
: { slots: defaultSlots(def) }),
}); });
if (!hasLink(map, map.territory.id, id)) { if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id }); map.links.push({ a: map.territory.id, b: id });
+81 -18
View File
@@ -361,16 +361,12 @@ export class PeoplePanel {
el('h3', { class: 'people__card-name', text: card.fullName }), el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }), el('p', { class: 'people__card-meta', text: cardMeta(card) }),
); );
appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`)); appendPairs(this.card, t('peopleBody'), card.body);
appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`)); appendPairs(this.card, t('peopleSkills'), card.skills);
appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label)); appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendStats( appendNeeds(this.card, t('peopleNeeds'), card.needs);
this.card,
t('peopleNeeds'),
card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`),
);
const family = el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: t('peopleFamily') })); const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id)); appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id)); appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id)); appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
@@ -445,17 +441,79 @@ function cardMeta(card: PersonCard): string {
return bits.join(' · '); return bits.join(' · ');
} }
function appendStats(parent: HTMLElement, title: string, values: readonly string[]): void { function section(title: string): HTMLElement {
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
}
/**
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
* the card past the fold while three quarters of its width sat empty.
*/
function appendPairs(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: string }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: row.label }),
el('dd', { text: row.value }),
),
);
}
parent.append(section(title), grid);
}
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) { if (values.length === 0) {
return; return;
} }
const list = el('ul', { class: 'people__stats' }); const tags = el('div', { class: 'people__tags' });
for (const value of values) { for (const value of values) {
list.append(el('li', { text: value })); tags.append(el('span', { class: 'people__tag', text: value }));
} }
parent.append(el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: title }), list)); parent.append(section(title), tags);
}
function appendNeeds(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: number }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('div', { class: 'people__needs' });
for (const row of rows) {
const share = Math.max(0, Math.min(1, row.value));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
grid.append(
el(
'div',
{ class: 'people__need' },
el('span', { class: 'people__need-label', text: row.label }),
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
el('span', { class: 'people__need-track' }, fill),
),
);
}
parent.append(section(title), grid);
} }
function appendRelatives( function appendRelatives(
@@ -468,10 +526,9 @@ function appendRelatives(
return; return;
} }
const list = el('ul', { class: 'people__stats' }); const list = el('span', { class: 'people__rel-list' });
for (const relative of relatives) { for (const relative of relatives) {
const item = el('li'); list.append(
item.append(
el('button', { el('button', {
class: 'people__link', class: 'people__link',
type: 'button', type: 'button',
@@ -479,8 +536,14 @@ function appendRelatives(
onClick: () => open(relative.id), onClick: () => open(relative.id),
}), }),
); );
list.append(item);
} }
parent.append(el('h4', { class: 'panel__section-title', text: title }), list); parent.append(
el(
'div',
{ class: 'people__rel' },
el('span', { class: 'people__rel-title', text: title }),
list,
),
);
} }
+32
View File
@@ -307,6 +307,7 @@ public sealed class CatalogLoader
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal); var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal); var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal); var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved) foreach (var (key, json) in resolved)
{ {
@@ -351,6 +352,9 @@ public sealed class CatalogLoader
case DefKind.NameSet: case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json); nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break; break;
case DefKind.Subject:
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
break;
} }
} }
@@ -369,6 +373,7 @@ public sealed class CatalogLoader
bodyAttributes, bodyAttributes,
needs, needs,
nameSets, nameSets,
subjects,
ru, ru,
en); en);
} }
@@ -421,6 +426,33 @@ public sealed class CatalogLoader
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'."); throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
} }
} }
if (room.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is a homeroom and cannot declare named slots.");
}
if (string.IsNullOrWhiteSpace(room.SeatThing) || !catalog.Things.TryGetValue(room.SeatThing, out var seat) || seat.Abstract)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing is missing or unknown.");
}
if (seat.PupilSlots <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' must have pupilSlots.");
}
if (room.DefaultSeats < 1 || room.DefaultSeats > byte.MaxValue)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' defaultSeats must be 1{byte.MaxValue}.");
}
}
else if (!string.IsNullOrWhiteSpace(room.SeatThing) || room.DefaultSeats != 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
} }
PeopleDefValidator.Validate(catalog); PeopleDefValidator.Validate(catalog);
+13
View File
@@ -21,6 +21,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes, IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs, IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets, IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, SubjectDef> subjects,
IReadOnlyDictionary<string, string> ru, IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en) IReadOnlyDictionary<string, string> en)
{ {
@@ -38,6 +39,7 @@ public sealed class DefCatalog
BodyAttributes = bodyAttributes; BodyAttributes = bodyAttributes;
Needs = needs; Needs = needs;
NameSets = nameSets; NameSets = nameSets;
Subjects = subjects;
_ru = ru; _ru = ru;
_en = en; _en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -78,6 +80,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; } public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
private readonly IReadOnlyDictionary<string, string> _ru; private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en; private readonly IReadOnlyDictionary<string, string> _en;
@@ -98,6 +102,7 @@ public sealed class DefCatalog
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName), DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName), DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName), DefKind.NameSet => NameSets.GetValueOrDefault(defName),
DefKind.Subject => Subjects.GetValueOrDefault(defName),
_ => null, _ => null,
}; };
@@ -143,6 +148,13 @@ public sealed class DefCatalog
/// </summary> /// </summary>
public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key; public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key;
/// <summary>
/// Whether this locale actually carries the key. <see cref="Text"/> falls back to the key
/// itself, which reads as a label in English and as debug output in Russian — so a test that
/// wants to prove nothing is unlabelled has to ask this instead of comparing strings.
/// </summary>
public bool HasText(string locale, string key) => TryText(locale, key, out _);
private bool TryText(string locale, string key, out string text) private bool TryText(string locale, string key, out string text)
{ {
var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru; var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru;
@@ -164,6 +176,7 @@ public sealed class DefCatalog
BodyAttributeDef => DefKind.BodyAttribute, BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need, NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet, NameSetDef => DefKind.NameSet,
SubjectDef => DefKind.Subject,
_ => throw new ArgumentOutOfRangeException(nameof(def)), _ => throw new ArgumentOutOfRangeException(nameof(def)),
}; };
+8
View File
@@ -15,6 +15,7 @@ public enum DefKind
BodyAttribute, BodyAttribute,
Need, Need,
NameSet, NameSet,
Subject,
} }
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary> /// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -64,8 +65,15 @@ public sealed class RoomDef : Def
/// <summary> /// <summary>
/// When true, a map room of this def with pupil slots becomes a roster class. /// When true, a map room of this def with pupil slots becomes a roster class.
/// Labs keep <see cref="ThingDef.PupilSlots"/> for lessons without forming a homeroom. /// Labs keep <see cref="ThingDef.PupilSlots"/> for lessons without forming a homeroom.
/// Homerooms are a seat count of <see cref="SeatThing"/>, not a table of named slots.
/// </summary> /// </summary>
public bool Homeroom { get; init; } public bool Homeroom { get; init; }
/// <summary>Thing whose <see cref="ThingDef.PupilSlots"/> × map seats is class capacity.</summary>
public string? SeatThing { get; init; }
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
public int DefaultSeats { get; init; }
} }
public sealed class BuildingDef : Def; public sealed class BuildingDef : Def;
+6
View File
@@ -55,6 +55,12 @@ public sealed class RoomNode
/// <summary>Optional designation such as a classroom number. The def label still supplies the noun.</summary> /// <summary>Optional designation such as a classroom number. The def label still supplies the noun.</summary>
public string? Label { get; init; } public string? Label { get; init; }
/// <summary>
/// Homeroom seat count. Capacity is <c>SeatThing.PupilSlots × Seats</c>. Named
/// <see cref="Slots"/> stay on rooms whose furnishing actually varies.
/// </summary>
public int Seats { get; init; }
public IReadOnlyList<SlotFill> Slots { get; init; } = []; public IReadOnlyList<SlotFill> Slots { get; init; } = [];
} }
+22 -2
View File
@@ -64,7 +64,7 @@ public static class MapValidator
throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building."); throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building.");
} }
ValidateSlotFills(room, catalog); ValidateRoomContents(room, catalog);
rooms[room.Id] = room; rooms[room.Id] = room;
} }
@@ -121,13 +121,33 @@ public static class MapValidator
} }
} }
private static void ValidateSlotFills(RoomNode room, DefCatalog catalog) private static void ValidateRoomContents(RoomNode room, DefCatalog catalog)
{ {
if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef)) if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef))
{ {
return; return;
} }
if (roomDef.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new MapValidationException($"Homeroom '{room.Id}' cannot fill named slots.");
}
if (room.Seats < 1 || room.Seats > byte.MaxValue)
{
throw new MapValidationException($"Homeroom '{room.Id}' seats must be 1{byte.MaxValue}.");
}
return;
}
if (room.Seats != 0)
{
throw new MapValidationException($"Room '{room.Id}' is not a homeroom and cannot set seats.");
}
var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal); var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal);
foreach (var fill in room.Slots) foreach (var fill in room.Slots)
{ {
+14 -13
View File
@@ -121,23 +121,24 @@ public static class MapView
string locale, string locale,
RoomNode room) RoomNode room)
{ {
var items = new List<MapViewItem>(room.Slots.Count); if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom && !string.IsNullOrWhiteSpace(def.SeatThing))
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{ {
var count = SlotQuantity(fill.Count); var seats = RoomOccupancy.Quantity(room.Seats > 0 ? room.Seats : def.DefaultSeats);
items.Add(new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, fill.Thing), count)); return (
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0) [new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, def.SeatThing), seats)],
{ RoomOccupancy.PupilSlots(catalog, room));
pupilSlots += (long)thing.PupilSlots * count;
}
} }
return (items, (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue)); var items = new List<MapViewItem>(room.Slots.Count);
} foreach (var fill in room.Slots)
{
items.Add(new MapViewItem(
LabelOf(catalog, locale, DefKind.Thing, fill.Thing),
RoomOccupancy.Quantity(fill.Count)));
}
private static int SlotQuantity(int count) => return (items, RoomOccupancy.PupilSlots(catalog, room));
count < 1 ? 1 : Math.Min(count, byte.MaxValue); }
/// <summary> /// <summary>
/// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the /// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the
+3
View File
@@ -101,6 +101,9 @@ internal static class PackPaths
case "namesets": case "namesets":
kind = DefKind.NameSet; kind = DefKind.NameSet;
return true; return true;
case "subjects":
kind = DefKind.Subject;
return true;
default: default:
kind = default; kind = default;
return false; return false;
+42
View File
@@ -29,6 +29,11 @@ internal static class PeopleDefValidator
ValidateNameSet(names); ValidateNameSet(names);
} }
foreach (var subject in catalog.Subjects.Values)
{
ValidateSubject(subject, catalog);
}
RequireBuildInputs(catalog); RequireBuildInputs(catalog);
} }
@@ -218,6 +223,43 @@ internal static class PeopleDefValidator
} }
} }
private static void ValidateSubject(SubjectDef subject, DefCatalog catalog)
{
if (subject.HoursPerWeek < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' hoursPerWeek cannot be negative.");
}
if (subject.Grades.Min < 1 || subject.Grades.Max > 11 || subject.Grades.Min > subject.Grades.Max)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' grades must be 111 with min ≤ max.");
}
if (subject.Skills.Count == 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill.");
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var share in subject.Skills)
{
if (string.IsNullOrWhiteSpace(share.Skill) || !seen.Add(share.Skill))
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' has a missing or duplicate skill.");
}
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown SkillDef '{share.Skill}'.");
}
if (share.Share < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
}
}
}
private static void ValidateNameSet(NameSetDef names) private static void ValidateNameSet(NameSetDef names)
{ {
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
+16
View File
@@ -122,6 +122,22 @@ public sealed class SkillDef : Def
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = []; public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
} }
public sealed class SubjectSkillShare
{
public required string Skill { get; init; }
public float Share { get; init; }
}
public sealed class SubjectDef : Def
{
public IntRange Grades { get; init; } = new() { Min = 1, Max = 11 };
public int HoursPerWeek { get; init; }
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
}
public sealed class TraitSkillModifier public sealed class TraitSkillModifier
{ {
public required string Skill { get; init; } public required string Skill { get; init; }
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.Content;
/// <summary>
/// How many pupils a map room can host. Homerooms are a seat count of one thing; other rooms
/// still sum <c>ThingDef.PupilSlots × fill count</c> across named slots.
/// </summary>
public static class RoomOccupancy
{
public static int Quantity(int count) =>
count < 1 ? 1 : Math.Min(count, byte.MaxValue);
public static int PupilSlots(DefCatalog catalog, RoomNode room)
{
if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom)
{
if (string.IsNullOrWhiteSpace(def.SeatThing)
|| !catalog.Things.TryGetValue(def.SeatThing, out var seat)
|| seat.PupilSlots <= 0)
{
return 0;
}
var seats = room.Seats > 0 ? Quantity(room.Seats) : Quantity(def.DefaultSeats);
return (int)Math.Clamp((long)seat.PupilSlots * seats, 0, ushort.MaxValue);
}
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * Quantity(fill.Count);
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ public static class RosterGenerator
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in /// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>. /// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
/// </summary> /// </summary>
public static readonly DateTime DefaultAsOf = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); public static readonly DateTime DefaultAsOf = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
public static Roster Generate( public static Roster Generate(
DefCatalog catalog, DefCatalog catalog,
+1 -16
View File
@@ -33,7 +33,7 @@ public sealed class SchoolDemand
foreach (var room in map.Rooms) foreach (var room in map.Rooms)
{ {
var slots = PupilSlotsOf(catalog, room); var slots = RoomOccupancy.PupilSlots(catalog, room);
if (catalog.Rooms.TryGetValue(room.Def, out var def)) if (catalog.Rooms.TryGetValue(room.Def, out var def))
{ {
foreach (var position in def.Positions) foreach (var position in def.Positions)
@@ -67,19 +67,4 @@ public sealed class SchoolDemand
return new SchoolDemand(classes, seats, staff); return new SchoolDemand(classes, seats, staff);
} }
private static int PupilSlotsOf(DefCatalog catalog, RoomNode room)
{
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
var count = fill.Count < 1 ? 1 : Math.Min(fill.Count, byte.MaxValue);
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
} }
+31
View File
@@ -87,6 +87,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<RoomInfoResponse> Rooms, IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things, IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets, IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
MapLayout DefaultMap) MapLayout DefaultMap)
{ {
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) => public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
@@ -97,6 +98,7 @@ internal sealed record CatalogResponse(
PlaceableRooms(catalog, locale), PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale), PlaceableThings(catalog, locale),
Placeable(catalog.NameSets.Values, catalog, locale), Placeable(catalog.NameSets.Values, catalog, locale),
PlaceableSubjects(catalog, locale),
map); map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale) private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
@@ -121,9 +123,25 @@ internal sealed record CatalogResponse(
.Select(def => new RoomInfoResponse( .Select(def => new RoomInfoResponse(
def.DefName, def.DefName,
catalog.Label(locale, def), catalog.Label(locale, def),
def.Homeroom,
def.SeatThing,
def.DefaultSeats,
def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(), def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(),
def.Positions.ToArray())) def.Positions.ToArray()))
.ToArray(); .ToArray();
private static IReadOnlyList<SubjectInfoResponse> PlaceableSubjects(DefCatalog catalog, string locale) =>
catalog.Subjects.Values
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new SubjectInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Grades.Min,
def.Grades.Max,
def.HoursPerWeek,
def.Skills.Select(share => new SubjectSkillInfo(share.Skill, share.Share)).ToArray()))
.ToArray();
} }
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0); internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
@@ -131,7 +149,20 @@ internal sealed record DefInfoResponse(string DefName, string Label, int PupilSl
internal sealed record RoomInfoResponse( internal sealed record RoomInfoResponse(
string DefName, string DefName,
string Label, string Label,
bool Homeroom,
string? SeatThing,
int DefaultSeats,
IReadOnlyList<RoomSlotInfo> Slots, IReadOnlyList<RoomSlotInfo> Slots,
IReadOnlyList<string> Positions); IReadOnlyList<string> Positions);
internal sealed record RoomSlotInfo(string Key, string Thing, int Count); internal sealed record RoomSlotInfo(string Key, string Thing, int Count);
internal sealed record SubjectSkillInfo(string Skill, float Share);
internal sealed record SubjectInfoResponse(
string DefName,
string Label,
int GradeMin,
int GradeMax,
int HoursPerWeek,
IReadOnlyList<SubjectSkillInfo> Skills);
+1 -1
View File
@@ -10,7 +10,7 @@
"TickRate": 20, "TickRate": 20,
"MaxSchools": 6, "MaxSchools": 6,
"GameMinutesPerRealSecond": 5, "GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00", "DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves", "SavesDirectory": "saves",
"ModsDirectory": "mods", "ModsDirectory": "mods",
"SaveIntervalSeconds": 30 "SaveIntervalSeconds": 30
@@ -4,6 +4,5 @@
{ "defName": "Teacher" }, { "defName": "Teacher" },
{ "defName": "Librarian" }, { "defName": "Librarian" },
{ "defName": "Nurse" }, { "defName": "Nurse" },
{ "defName": "PETeacher" },
{ "defName": "CafeteriaCook" }, { "defName": "CafeteriaCook" },
] ]
@@ -1,15 +1,10 @@
[ [
{ {
"defName": "Classroom", "defName": "Classroom",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"positions": ["Teacher"],
"works": ["TeachLesson"],
"homeroom": true, "homeroom": true,
"seatThing": "StudentDesk",
"defaultSeats": 16,
"works": ["TeachLesson"],
}, },
{ {
"defName": "Library", "defName": "Library",
@@ -27,7 +22,6 @@
{ "key": "teacherChair", "thing": "Chair" }, { "key": "teacherChair", "thing": "Chair" },
{ "key": "computers", "thing": "Computer", "count": 12 }, { "key": "computers", "thing": "Computer", "count": 12 },
], ],
"positions": ["Teacher"],
"works": ["TeachLesson"], "works": ["TeachLesson"],
}, },
] ]
@@ -3,6 +3,5 @@
"slots": [ "slots": [
{ "key": "benches", "thing": "Bench", "count": 4 }, { "key": "benches", "thing": "Bench", "count": 4 },
], ],
"positions": ["PETeacher"],
"works": ["PELesson"], "works": ["PELesson"],
} }
@@ -0,0 +1,79 @@
[
{
"defName": "PrimarySchool",
"grades": { "min": 1, "max": 4 },
"hoursPerWeek": 20,
"skills": [
{ "skill": "Mathematics", "share": 0.3 },
{ "skill": "RussianLanguage", "share": 0.3 },
{ "skill": "Literature", "share": 0.2 },
{ "skill": "Biology", "share": 0.2 },
],
},
{
"defName": "Mathematics",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 5,
"skills": [{ "skill": "Mathematics", "share": 1 }],
},
{
"defName": "RussianLanguage",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 4,
"skills": [{ "skill": "RussianLanguage", "share": 1 }],
},
{
"defName": "Literature",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "Literature", "share": 1 }],
},
{
"defName": "ForeignLanguage",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "ForeignLanguage", "share": 1 }],
},
{
"defName": "History",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "History", "share": 1 }],
},
{
"defName": "Geography",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Geography", "share": 1 }],
},
{
"defName": "Biology",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Biology", "share": 1 }],
},
{
"defName": "Informatics",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 1,
"skills": [{ "skill": "Informatics", "share": 1 }],
},
{
"defName": "Physics",
"grades": { "min": 7, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Physics", "share": 1 }],
},
{
"defName": "Chemistry",
"grades": { "min": 8, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Chemistry", "share": 1 }],
},
{
"defName": "PhysicalEducation",
"grades": { "min": 1, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "PhysicalEducation", "share": 1 }],
},
]
@@ -16,8 +16,8 @@
"Teacher": "Teacher", "Teacher": "Teacher",
"Librarian": "Librarian", "Librarian": "Librarian",
"Nurse": "Nurse", "Nurse": "Nurse",
"PETeacher": "PE teacher",
"CafeteriaCook": "Cook", "CafeteriaCook": "Cook",
"PrimarySchool": "Primary",
"PrincipalOfficeWork": "Principal's work", "PrincipalOfficeWork": "Principal's work",
"TeachLesson": "Teach a lesson", "TeachLesson": "Teach a lesson",
"WalkSchool": "Walk the school", "WalkSchool": "Walk the school",
@@ -87,6 +87,7 @@
"Toilet": "Toilet", "Toilet": "Toilet",
"Social": "Social", "Social": "Social",
"Slavic": "Slavic", "Slavic": "Slavic",
"Build": "Build",
"Skinny": "Skinny", "Skinny": "Skinny",
"Average": "Average", "Average": "Average",
"Athletic": "Athletic", "Athletic": "Athletic",
@@ -16,8 +16,8 @@
"Teacher": "Учитель", "Teacher": "Учитель",
"Librarian": "Библиотекарь", "Librarian": "Библиотекарь",
"Nurse": "Медсестра", "Nurse": "Медсестра",
"PETeacher": "Учитель физкультуры",
"CafeteriaCook": "Повар", "CafeteriaCook": "Повар",
"PrimarySchool": "Начальные классы",
"PrincipalOfficeWork": "Работа директора", "PrincipalOfficeWork": "Работа директора",
"TeachLesson": "Урок", "TeachLesson": "Урок",
"WalkSchool": "Обход школы", "WalkSchool": "Обход школы",
@@ -87,6 +87,7 @@
"Toilet": "Туалет", "Toilet": "Туалет",
"Social": "Общение", "Social": "Общение",
"Slavic": "Славянский", "Slavic": "Славянский",
"Build": "Телосложение",
"Skinny": "Худощавое", "Skinny": "Худощавое",
"Average": "Обычное", "Average": "Обычное",
"Athletic": "Атлетическое", "Athletic": "Атлетическое",
+11 -66
View File
@@ -52,12 +52,7 @@
"building": "main", "building": "main",
"floor": "floor-1", "floor": "floor-1",
"label": "101", "label": "101",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-102", "id": "classroom-102",
@@ -65,12 +60,7 @@
"building": "main", "building": "main",
"floor": "floor-1", "floor": "floor-1",
"label": "102", "label": "102",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-103", "id": "classroom-103",
@@ -78,12 +68,7 @@
"building": "main", "building": "main",
"floor": "floor-1", "floor": "floor-1",
"label": "103", "label": "103",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-104", "id": "classroom-104",
@@ -91,12 +76,7 @@
"building": "main", "building": "main",
"floor": "floor-1", "floor": "floor-1",
"label": "104", "label": "104",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "cafeteria", "id": "cafeteria",
@@ -127,12 +107,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "201", "label": "201",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-202", "id": "classroom-202",
@@ -140,12 +115,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "202", "label": "202",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-203", "id": "classroom-203",
@@ -153,12 +123,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "203", "label": "203",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-204", "id": "classroom-204",
@@ -166,12 +131,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "204", "label": "204",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-205", "id": "classroom-205",
@@ -179,12 +139,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "205", "label": "205",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-206", "id": "classroom-206",
@@ -192,12 +147,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "206", "label": "206",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "classroom-207", "id": "classroom-207",
@@ -205,12 +155,7 @@
"building": "main", "building": "main",
"floor": "floor-2", "floor": "floor-2",
"label": "207", "label": "207",
"slots": [ "seats": 16,
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
}, },
{ {
"id": "library", "id": "library",
+1 -1
View File
@@ -5,7 +5,7 @@ public sealed class SimulationOptions
{ {
public const string SectionName = "Simulation"; public const string SectionName = "Simulation";
private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); private DateTime _defaultStartDate = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
/// <summary>Fixed simulation steps per second.</summary> /// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20; public int TickRate { get; set; } = 20;
@@ -109,7 +109,7 @@ public class GameSocketTests(AppHostFixture fixture)
var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-101"); var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-101");
Assert.Equal(16, classroom.PupilSlots); Assert.Equal(16, classroom.PupilSlots);
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
Assert.Contains(classroom.Items, item => item.Name == "Стул" && item.Count == 1); Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
} }
[Fact] [Fact]
+20 -2
View File
@@ -9,7 +9,7 @@ namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)] [Collection(AppHostCollection.Name)]
public class SchoolApiTests(AppHostFixture fixture) public class SchoolApiTests(AppHostFixture fixture)
{ {
private static readonly DateTime ExpectedDefaultStart = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact] [Fact]
public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate() public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate()
@@ -159,6 +159,14 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("yard", ru.DefaultMap.Territory?.Id); Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label); Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label); Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label);
Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label);
var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom");
Assert.True(classroom.Homeroom);
Assert.Equal("StudentDesk", classroom.SeatThing);
Assert.Equal(16, classroom.DefaultSeats);
Assert.Empty(classroom.Slots);
Assert.Empty(classroom.Positions);
} }
[Fact] [Fact]
@@ -358,14 +366,24 @@ public class SchoolApiTests(AppHostFixture fixture)
IReadOnlyList<RoomInfoResponse> Rooms, IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things, IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets, IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
MapLayoutResponse DefaultMap); MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label); private sealed record DefInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(string DefName, string Label, IReadOnlyList<RoomSlotResponse> Slots, IReadOnlyList<string> Positions); private sealed record RoomInfoResponse(
string DefName,
string Label,
bool Homeroom,
string? SeatThing,
int DefaultSeats,
IReadOnlyList<RoomSlotResponse> Slots,
IReadOnlyList<string> Positions);
private sealed record RoomSlotResponse(string Key, string Thing); private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record SubjectInfoResponse(string DefName, string Label);
private sealed record MapLayoutResponse(TerritoryResponse? Territory); private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def); private sealed record TerritoryResponse(string Id, string Def);
+2 -9
View File
@@ -43,16 +43,9 @@ public class MapViewTests
var classroom = ru.Single(node => node.Id == "classroom-101"); var classroom = ru.Single(node => node.Id == "classroom-101");
Assert.Equal("Класс 101", classroom.Name); Assert.Equal("Класс 101", classroom.Name);
Assert.Equal( Assert.Equal([new MapViewItem("Парта", 16)], classroom.Items);
[
new MapViewItem("Доска", 1),
new MapViewItem("Стол", 1),
new MapViewItem("Стул", 1),
new MapViewItem("Парта", 16),
],
classroom.Items);
Assert.Equal(16, classroom.PupilSlots); Assert.Equal(16, classroom.PupilSlots);
Assert.Equal(["Учитель"], classroom.Positions); Assert.Empty(classroom.Positions);
Assert.Equal("Classroom 101", en.Single(node => node.Id == "classroom-101").Name); Assert.Equal("Classroom 101", en.Single(node => node.Id == "classroom-101").Name);
} }
@@ -23,6 +23,9 @@ public class PeopleDefTests
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"])); Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"])); Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"]));
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"])); Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
} }
[Fact] [Fact]
@@ -147,6 +150,30 @@ public class PeopleDefTests
Assert.Contains("Ghost", ex.Message); Assert.Contains("Ghost", ex.Message);
} }
[Fact]
public void Subject_UnknownSkill_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "skills", "math", """{ "defName": "Mathematics" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"subjects",
"ghost",
"""
{
"defName": "GhostSubject",
"grades": { "min": 1, "max": 4 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Missing", "share": 1 }]
}
"""),
]));
Assert.Contains("Missing", ex.Message);
}
[Fact] [Fact]
public void OneWayIncompatibility_IsMutual() public void OneWayIncompatibility_IsMutual()
{ {
@@ -26,24 +26,80 @@ public class VanillaCoreTests
Assert.True(catalog.Rooms["Classroom"].Homeroom); Assert.True(catalog.Rooms["Classroom"].Homeroom);
Assert.False(catalog.Rooms["ComputerLab"].Homeroom); Assert.False(catalog.Rooms["ComputerLab"].Homeroom);
Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"])); Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"]));
Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "Classroom")); Assert.Empty(catalog.PositionsFor(DefKind.Room, "Classroom"));
Assert.Empty(catalog.PositionsFor(DefKind.Room, "ComputerLab"));
Assert.Empty(catalog.PositionsFor(DefKind.Room, "GymHall"));
Assert.False(catalog.Positions.ContainsKey("PETeacher"));
Assert.Equal("StudentDesk", catalog.Rooms["Classroom"].SeatThing);
Assert.Equal(16, catalog.Rooms["Classroom"].DefaultSeats);
Assert.Empty(catalog.Rooms["Classroom"].Slots);
Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots); Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots);
Assert.Equal(1, catalog.Things["Computer"].PupilSlots); Assert.Equal(1, catalog.Things["Computer"].PupilSlots);
Assert.Equal(0, catalog.Things["Desk"].PupilSlots); Assert.Equal(0, catalog.Things["Desk"].PupilSlots);
Assert.Equal(0, catalog.Things["Chair"].PupilSlots); Assert.Equal(0, catalog.Things["Chair"].PupilSlots);
Assert.Contains(catalog.Rooms["Classroom"].Slots, slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.Equal(16, catalog.Rooms["Classroom"].Slots.Single(slot => slot.Key == "studentDesks").Count); Assert.Equal(1, catalog.Subjects["PrimarySchool"].Grades.Min);
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
Assert.Equal(2, map.Buildings.Count); Assert.Equal(2, map.Buildings.Count);
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
Assert.Equal(11, homerooms.Count); Assert.Equal(11, homerooms.Count);
Assert.Contains(map.Rooms, room => room.Id == "classroom-101" && room.Label == "101"); Assert.Contains(map.Rooms, room => room.Id == "classroom-101" && room.Label == "101");
Assert.Contains(map.Rooms, room => room.Id == "classroom-207" && room.Label == "207"); Assert.Contains(map.Rooms, room => room.Id == "classroom-207" && room.Label == "207");
Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B"); Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B");
Assert.Equal( Assert.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats);
16, Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots);
map.Rooms.Single(room => room.Id == "classroom-101").Slots.Single(slot => slot.Key == "studentDesks").Count);
Assert.Contains(
map.Rooms.Single(room => room.Id == "classroom-101").Slots,
slot => slot.Key == "teacherChair" && slot.Thing == "Chair");
} }
/// <summary>
/// Every name a card can print has to exist in both locales. Missing ones do not fail — they
/// fall through to the raw key, which reads as a label in English and as a leak in Russian:
/// the build row showed "Build: Обычное" for exactly this reason.
/// </summary>
[Fact]
public void EveryVanillaLabel_ExistsInBothLocales()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var catalog = new CatalogLoader().Load(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
var keys = new List<string>();
keys.AddRange(Names(catalog.Actions.Values));
keys.AddRange(Names(catalog.Things.Values));
keys.AddRange(Names(catalog.Positions.Values));
keys.AddRange(Names(catalog.Works.Values));
keys.AddRange(Names(catalog.Rooms.Values));
keys.AddRange(Names(catalog.Buildings.Values));
keys.AddRange(Names(catalog.Floors.Values));
keys.AddRange(Names(catalog.Territories.Values));
keys.AddRange(Names(catalog.Skills.Values));
keys.AddRange(Names(catalog.Traits.Values));
keys.AddRange(Names(catalog.BodyAttributes.Values));
keys.AddRange(Names(catalog.Needs.Values));
keys.AddRange(Names(catalog.NameSets.Values));
keys.AddRange(Names(catalog.Subjects.Values));
// Derived in code, so no def carries them.
keys.Add(BodyBuilds.Attribute);
keys.AddRange(BodyBuilds.Values);
foreach (var value in catalog.BodyAttributes.Values.SelectMany(def => def.Options))
{
keys.Add(value.Value);
}
var missing = keys
.Distinct(StringComparer.Ordinal)
.SelectMany(key => new[] { ("ru", key), ("en", key) })
.Where(pair => !catalog.HasText(pair.Item1, pair.Item2))
.Select(pair => $"{pair.Item1}:{pair.Item2}")
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Empty(missing);
}
private static IEnumerable<string> Names(IEnumerable<Def> defs) =>
defs.Where(def => !def.Abstract).Select(def => def.DefName);
} }
+16 -5
View File
@@ -47,10 +47,7 @@ internal static class Fixtures
Building = "main", Building = "main",
Floor = "floor-1", Floor = "floor-1",
Label = $"{101 + i}", Label = $"{101 + i}",
Slots = Seats = desks,
[
new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks },
],
}; };
} }
@@ -72,7 +69,7 @@ internal static class Fixtures
Building = "main", Building = "main",
Floor = "floor-1", Floor = "floor-1",
Label = "101", Label = "101",
Slots = [new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = 1 }], Seats = 1,
}, },
}; };
@@ -84,6 +81,20 @@ internal static class Fixtures
return new MapLayout { Rooms = rooms }; return new MapLayout { Rooms = rooms };
} }
public static MapLayout VanillaMap()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var map = CatalogLoader.LastDefaultMap(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
if (map is null)
{
throw new InvalidOperationException("Vanilla default map is missing.");
}
return map;
}
public static Roster Generate(MapLayout map, int seed = SchoolSeed) => public static Roster Generate(MapLayout map, int seed = SchoolSeed) =>
RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf); RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf);
@@ -55,7 +55,7 @@ public class RosterBrowserTests
[Fact] [Fact]
public void ParentFilter_IncludesStaffWhoAreAlsoParents() public void ParentFilter_IncludesStaffWhoAreAlsoParents()
{ {
var roster = Fixtures.Generate(Fixtures.Classrooms(4)); var roster = Fixtures.Generate(Fixtures.VanillaMap());
var page = RosterBrowser.Apply( var page = RosterBrowser.Apply(
roster, roster,
Fixtures.AsOf, Fixtures.AsOf,
@@ -104,7 +104,7 @@ public class RosterGeneratorTests
[Fact] [Fact]
public void SomeStaffAreAlsoParents() public void SomeStaffAreAlsoParents()
{ {
var roster = Fixtures.Generate(Fixtures.Classrooms(4)); var roster = Fixtures.Generate(Fixtures.VanillaMap());
Assert.Contains(roster.People, person => person.IsStaff && person.IsParent); Assert.Contains(roster.People, person => person.IsStaff && person.IsParent);
} }
@@ -122,7 +122,9 @@ public class RosterGeneratorTests
Assert.Equal(11, demand.Classes.Count); Assert.Equal(11, demand.Classes.Count);
Assert.Equal(11 * 16, demand.Seats.Count); Assert.Equal(11 * 16, demand.Seats.Count);
Assert.DoesNotContain(demand.Classes, schoolClass => schoolClass.RoomId == "computer-lab"); Assert.DoesNotContain(demand.Classes, schoolClass => schoolClass.RoomId == "computer-lab");
Assert.Contains(demand.Staff, opening => opening.RoomId == "computer-lab" && opening.Position == "Teacher"); Assert.DoesNotContain(demand.Staff, opening => opening.Position == "Teacher");
Assert.DoesNotContain(demand.Staff, opening => opening.RoomId == "computer-lab");
Assert.Contains(demand.Staff, opening => opening.RoomId == "library" && opening.Position == "Librarian");
} }
[Fact] [Fact]
@@ -163,10 +163,7 @@ public class PeopleInSchoolTests
Building = "main", Building = "main",
Floor = "floor-1", Floor = "floor-1",
Label = $"{101 + i}", Label = $"{101 + i}",
Slots = Seats = desks,
[
new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks },
],
}; };
} }