diff --git a/README.md b/README.md index 46f9c5b..d8cbd86 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Simulation tunables live under the `Simulation` section of | `TickRate` | 20 | fixed simulation steps per second | | `MaxSchools` | 6 | how many schools may exist at once | | `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 | | `ModsDirectory` | `mods` | pack folders; `core` is required | | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | diff --git a/docs/design/schedule.md b/docs/design/schedule.md new file mode 100644 index 0000000..c8543ba --- /dev/null +++ b/docs/design/schedule.md @@ -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` | +| Люди и место | Находятся по расписанию; ходьбы нет | diff --git a/docs/design/staffing.md b/docs/design/staffing.md new file mode 100644 index 0000000..d4799e4 --- /dev/null +++ b/docs/design/staffing.md @@ -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` на человека; сверх — нужен второй учитель предмета | +| Бюджет | Фиксированная сумма в месяц из настроек; баланса нет | +| Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия | +| Экран | Два верхних раздела под часами: «Обзор» и «Управление» | +| Внутри управления | Список слева, карточка человека справа — та же, что в «Людях» | diff --git a/docs/phases/10-subjects.md b/docs/phases/10-subjects.md new file mode 100644 index 0000000..eabb50c --- /dev/null +++ b/docs/phases/10-subjects.md @@ -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` проходят + +## Стоп + +Не трогать наём. Генератор пока закрывает должности сам — школа опустеет в следующей фазе. diff --git a/docs/phases/11-applicants.md b/docs/phases/11-applicants.md new file mode 100644 index 0000000..10f8afa --- /dev/null +++ b/docs/phases/11-applicants.md @@ -0,0 +1,44 @@ +# Фаза 11. Пустая школа и пул соискателей + +## Зависимости + +- [Фаза 10](10-subjects.md) +- [Фаза 6](06-people-generator.md) + +## Зачем + +Школа открывается без единого сотрудника, а рядом появляется пул людей, готовых наняться. +Нанимать пока нечем — это фаза про данные. + +## Задачи + +- [ ] `RosterGenerator` больше не закрывает должности: новая школа получает учеников, родителей + и ноль сотрудников +- [ ] Пул соискателей в `HSchool.People`: список полноценных `Person`, детерминированный от сида + школы и номера недели +- [ ] Соискателем может стать родитель ученика — тот же человек, без второй сущности +- [ ] Базовый запрос по зарплате считается из навыков соискателя; шкала — в данных, не в коде +- [ ] Еженедельное обновление: часть уходит, часть остаётся с теми же именами и запросом, + приходят новые. Полная замена запрещена +- [ ] Пул живёт в `saves/{id}.people.json` рядом с ростером и пишется по тем же правилам — + при изменении состава, не по таймеру +- [ ] Воркер публикует пул вместе со снимком ростера + +## Тесты, без которых фаза не закрыта + +- [ ] Тот же сид и та же неделя — тот же пул +- [ ] После обновления часть людей та же самая, с теми же навыками и тем же запросом +- [ ] Пул не пустеет и не растёт бесконечно за пятьдесят недель +- [ ] Запрос по зарплате у сильного кандидата выше, чем у слабого +- [ ] Новая школа не имеет ни одного сотрудника, но все ученические места заняты +- [ ] Соискатель не попадает в ростер школы, пока не нанят + +## Критерий готовности + +- Создать школу — в списке людей есть ученики и родители, работников нет +- Перезапуск сервера поднимает тот же пул +- `dotnet test` проходит + +## Стоп + +Ни найма, ни денег, ни назначения предметов. Пул только существует и обновляется. diff --git a/docs/phases/12-hiring-budget.md b/docs/phases/12-hiring-budget.md new file mode 100644 index 0000000..ac45ae6 --- /dev/null +++ b/docs/phases/12-hiring-budget.md @@ -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` проходит + +## Стоп + +Не увольнять. Не платить зарплату по месяцам — предел проверяется, деньги не движутся. +Не расписание. diff --git a/docs/phases/13-management-tab.md b/docs/phases/13-management-tab.md new file mode 100644 index 0000000..79477f8 --- /dev/null +++ b/docs/phases/13-management-tab.md @@ -0,0 +1,36 @@ +# Фаза 13. Раздел «Управление» + +## Зависимости + +- [Фаза 12](12-hiring-budget.md) +- [Фаза 8](08-people-browser.md) + +## Зачем + +Игрок собирает школу руками: видит деньги, перебирает соискателей, нанимает и раздаёт предметы. + +## Задачи + +- [ ] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что + есть сейчас; часы и дата видны в обоих +- [ ] Строка денег: выделено, расписано, свободно +- [ ] Список непокрытых предметов — что школа не может преподавать +- [ ] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку + человека справа — ту же, что в «Людях» +- [ ] Кнопка найма; отказ по пределу показывается текстом, а не молчанием +- [ ] Список штата: кто нанят, какие предметы ведёт, сколько стоит +- [ ] Назначение и снятие предмета с карточки нанятого +- [ ] Строки через `t(...)`, обе локали + +## Критерий готовности + +- Создать школу, открыть «Управление», нанять учителя, назначить ему математику +- Дойти до предела — попытка нанять ещё одного объясняет, что денег нет +- Снять предмет и убедиться, что свободных денег стало больше +- Перейти в «Обзор» → «Люди» и увидеть нанятого в общем списке с ролью работника +- Нанять родителя ученика и увидеть у него обе роли на одной карточке +- `dotnet test` и клиентские `npm test` / `build` проходят + +## Стоп + +Не увольнять из интерфейса. Не показывать расписание и нагрузку по часам — только предметы. diff --git a/docs/phases/14-school-calendar.md b/docs/phases/14-school-calendar.md new file mode 100644 index 0000000..b80e962 --- /dev/null +++ b/docs/phases/14-school-calendar.md @@ -0,0 +1,33 @@ +# Фаза 14. Каркас дня и каникулы + +## Зависимости + +- [Фаза 10](10-subjects.md) + +## Зачем + +Школа получает учебный календарь: во сколько звонок, сколько уроков, какие дни рабочие и когда +каникулы. Расписания ещё нет — есть время, в которое его можно будет положить. + +## Задачи + +- [ ] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен + и какая из них большая +- [ ] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию +- [ ] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года +- [ ] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому + времени, выходным и каникулам +- [ ] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в + компьютерном классе; без требования — кабинет класса +- [ ] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания + +## Критерий готовности + +- В субботу, в каникулы и в 23:00 запрос слота отвечает «занятий нет» +- В 10:20 буднего учебного дня отвечает конкретным номером урока +- Смена длины недели в настройках делает субботу рабочей +- `dotnet test` проходит + +## Стоп + +Ни одного урока не ставится. Ни планировщика, ни экрана. diff --git a/docs/phases/15-timetable-planner.md b/docs/phases/15-timetable-planner.md new file mode 100644 index 0000000..6379864 --- /dev/null +++ b/docs/phases/15-timetable-planner.md @@ -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` + +## Стоп + +Не трогать сервер, сейв и клиент. Таблица пока никуда не уезжает. diff --git a/docs/phases/16-timetable-in-school.md b/docs/phases/16-timetable-in-school.md new file mode 100644 index 0000000..f7392bc --- /dev/null +++ b/docs/phases/16-timetable-in-school.md @@ -0,0 +1,38 @@ +# Фаза 16. Расписание в школе + +## Зависимости + +- [Фаза 15](15-timetable-planner.md) + +## Зачем + +Расписание живёт в школе, переживает перезапуск и отвечает на вопрос «кто где сейчас». Экрана +ещё нет. + +## Задачи + +- [ ] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику** +- [ ] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые + игроком уроки сохраняются вместе с ним +- [ ] Воркер публикует расписание снимком, как ростер +- [ ] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием +- [ ] Команда закрепления и снятия закрепления урока — через мейлбокс воркера +- [ ] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает + опубликованный снимок +- [ ] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md` + правятся тем же коммитом, версия бумпится +- [ ] Панель локации получает данные для «Сейчас» и «Персонажей» + +## Критерий готовности + +- Нанять учителя математики — в расписании появились уроки математики +- Снять назначение — уроки исчезли, предмет стал непокрытым +- Передвинуть урок, нанять ещё одного человека — передвинутый остался на месте +- Перемотать время на ×4 через звонок — «кто где сейчас» меняется вместе с уроком +- В каникулы и в выходной никто нигде не находится +- Перезапуск сервера поднимает то же расписание с теми же закреплениями +- `dotnet test` проходит + +## Стоп + +Не рисовать расписание на экране. Не перемещать людей по карте. diff --git a/docs/phases/17-timetable-screen.md b/docs/phases/17-timetable-screen.md new file mode 100644 index 0000000..4cb015f --- /dev/null +++ b/docs/phases/17-timetable-screen.md @@ -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` проходят + +## Стоп + +Не общая сетка на всю школу. Не оценки и не посещаемость. Не анимация перемещений. diff --git a/docs/phases/README.md b/docs/phases/README.md index 05bd517..486d590 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -36,3 +36,35 @@ | [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | | [8. Просмотр людей](08-people-browser.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) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | diff --git a/docs/protocol.md b/docs/protocol.md index 25524b5..14671c2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -35,10 +35,10 @@ Everything the main menu needs in one request. ```json { "maxSchools": 6, - "defaultStartDate": "2012-04-03T06:00:00Z", + "defaultStartDate": "2012-03-31T06:00:00Z", "gameMinutesPerRealSecond": 5, "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 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 -`pupilSlots` — how many pupils that thing hosts for lessons. The create editor copies both onto -the map instance; the snapshot's per-node pupil-slot total is computed on the server. +Room defs that are homerooms carry `homeroom`, `seatThing` and `defaultSeats` instead of a +slot table. A map classroom stores `seats` — how many of that thing occupy the room. Capacity is +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; 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 Russian. @@ -83,7 +92,7 @@ Body: ```json { "name": "Гимназия №14", - "startDate": "2012-04-03T06:00:00Z", + "startDate": "2012-03-31T06:00:00Z", "modIds": [], "map": null, "nameSetId": "Slavic" diff --git a/src/HSchool.Client/src/format/gameTime.test.ts b/src/HSchool.Client/src/format/gameTime.test.ts index 2b98588..df9a93b 100644 --- a/src/HSchool.Client/src/format/gameTime.test.ts +++ b/src/HSchool.Client/src/format/gameTime.test.ts @@ -9,8 +9,8 @@ import { toDateAndTimeInputs, } from './gameTime.ts'; -// The default start of a new school: 3 April 2012, 06:00 — a Tuesday. -const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0)); +// The default start of a new school: 31 March 2012, 06:00 — a Saturday. +const START = new Date(Date.UTC(2012, 2, 31, 6, 0, 0)); const initial = getLocale(); beforeEach(() => setLocale('ru')); @@ -22,25 +22,25 @@ describe('game time formatting', () => { }); it('shows the weekday of the game date', () => { - expect(formatGameWeekday(START)).toBe('вторник'); + expect(formatGameWeekday(START)).toBe('суббота'); }); it('shows the full date', () => { expect(formatGameDate(START)).toContain('2012'); - expect(formatGameDate(START)).toContain('апреля'); + expect(formatGameDate(START)).toContain('марта'); }); 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'); }); it('formats the same instant in English when the locale is en', () => { setLocale('en'); - expect(formatGameWeekday(START)).toBe('Tuesday'); - expect(formatGameDate(START)).toContain('April'); - expect(formatGameDateTime(START)).toContain('03/04/2012'); + expect(formatGameWeekday(START)).toBe('Saturday'); + expect(formatGameDate(START)).toContain('March'); + expect(formatGameDateTime(START)).toContain('31/03/2012'); expect(formatGameTimeOfDay(START)).toBe('06:00'); }); @@ -55,7 +55,7 @@ describe('game time formatting', () => { describe('date inputs', () => { 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', () => { diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 3be4310..2f9780e 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -65,6 +65,7 @@ const ru = { removeRoom: 'Удалить комнату', slotEmpty: '— пусто —', slotCount: 'Количество', + editorSeats: 'Мест', backToMenu: '← В главное меню', pause: 'Пауза', @@ -193,6 +194,7 @@ const en: Messages = { removeRoom: 'Remove room', slotEmpty: '— empty —', slotCount: 'Count', + editorSeats: 'Seats', backToMenu: '← Main menu', pause: 'Pause', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 86e906f..a4abb87 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -85,10 +85,27 @@ export interface RoomSlotInfo { export interface RoomInfo { readonly defName: string; readonly label: string; + readonly homeroom: boolean; + readonly seatThing?: string | null; + readonly defaultSeats: number; readonly slots: readonly RoomSlotInfo[]; 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 { territory: { id: string; def: string }; buildings: { id: string; def: string }[]; @@ -99,6 +116,7 @@ export interface MapLayout { building: string; floor: string; label?: string; + seats?: number; slots?: { key: string; thing: string; count?: number }[]; }[]; links: { a: string; b: string }[]; @@ -112,6 +130,7 @@ export interface CatalogResponse { readonly things: readonly DefInfo[]; readonly defaultMap: MapLayout; readonly nameSets: readonly DefInfo[]; + readonly subjects: readonly SubjectInfo[]; } export async function fetchMods(): Promise { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 9ff9dfd..a485165 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -532,8 +532,9 @@ body { } .people__card-name { - margin: 0 0 4px; - font-size: 16px; + margin: 0 0 2px; + font-size: 15px; + font-weight: 600; } .people__card-meta { @@ -542,12 +543,122 @@ body { } .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; - 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 { diff --git a/src/HSchool.Client/src/ui/mapEditor.ts b/src/HSchool.Client/src/ui/mapEditor.ts index e5104d1..80cf4a7 100644 --- a/src/HSchool.Client/src/ui/mapEditor.ts +++ b/src/HSchool.Client/src/ui/mapEditor.ts @@ -103,52 +103,78 @@ export function mapEditor(options: MapEditorOptions): { } const rows: HTMLElement[] = []; - 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; + if (roomDef.homeroom) { + const seatsInput = el('input', { class: 'input input--count', type: 'number' }); + seatsInput.min = '1'; + seatsInput.max = '255'; + seatsInput.step = '1'; + seatsInput.value = String(room.seats ?? roomDef.defaultSeats ?? 16); + seatsInput.title = t('editorSeats'); + seatsInput.addEventListener('change', () => { + 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); + room.seats = seats; + room.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, + el('span', { class: 'field__label', text: t('editorSeats') }), + seatsInput, ), ); + } 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( @@ -251,7 +277,9 @@ export function mapEditor(options: MapEditorOptions): { def: def.defName, building: building.id, floor: floor.id, - slots: defaultSlots(def), + ...(def.homeroom + ? { seats: def.defaultSeats || 16 } + : { slots: defaultSlots(def) }), }); if (!hasLink(map, map.territory.id, id)) { map.links.push({ a: map.territory.id, b: id }); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index e54aedf..68491d2 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -361,16 +361,12 @@ export class PeoplePanel { el('h3', { class: 'people__card-name', text: card.fullName }), el('p', { class: 'people__card-meta', text: cardMeta(card) }), ); - appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`)); - appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`)); - appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label)); - appendStats( - this.card, - t('peopleNeeds'), - card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`), - ); + appendPairs(this.card, t('peopleBody'), card.body); + appendPairs(this.card, t('peopleSkills'), card.skills); + appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label)); + appendNeeds(this.card, t('peopleNeeds'), card.needs); - 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('peopleChildren'), card.family.children, (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(' · '); } -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) { return; } - const list = el('ul', { class: 'people__stats' }); + const tags = el('div', { class: 'people__tags' }); 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( @@ -468,10 +526,9 @@ function appendRelatives( return; } - const list = el('ul', { class: 'people__stats' }); + const list = el('span', { class: 'people__rel-list' }); for (const relative of relatives) { - const item = el('li'); - item.append( + list.append( el('button', { class: 'people__link', type: 'button', @@ -479,8 +536,14 @@ function appendRelatives( 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, + ), + ); } diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 61e248e..22409f1 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -307,6 +307,7 @@ public sealed class CatalogLoader var bodyAttributes = new Dictionary(StringComparer.Ordinal); var needs = new Dictionary(StringComparer.Ordinal); var nameSets = new Dictionary(StringComparer.Ordinal); + var subjects = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -351,6 +352,9 @@ public sealed class CatalogLoader case DefKind.NameSet: nameSets[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Subject: + subjects[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -369,6 +373,7 @@ public sealed class CatalogLoader bodyAttributes, needs, nameSets, + subjects, ru, en); } @@ -421,6 +426,33 @@ public sealed class CatalogLoader 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); diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 4ed6d41..15ccd81 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -21,6 +21,7 @@ public sealed class DefCatalog IReadOnlyDictionary bodyAttributes, IReadOnlyDictionary needs, IReadOnlyDictionary nameSets, + IReadOnlyDictionary subjects, IReadOnlyDictionary ru, IReadOnlyDictionary en) { @@ -38,6 +39,7 @@ public sealed class DefCatalog BodyAttributes = bodyAttributes; Needs = needs; NameSets = nameSets; + Subjects = subjects; _ru = ru; _en = en; AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); @@ -78,6 +80,8 @@ public sealed class DefCatalog public IReadOnlyDictionary NameSets { get; } + public IReadOnlyDictionary Subjects { get; } + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _en; @@ -98,6 +102,7 @@ public sealed class DefCatalog DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName), DefKind.Need => Needs.GetValueOrDefault(defName), DefKind.NameSet => NameSets.GetValueOrDefault(defName), + DefKind.Subject => Subjects.GetValueOrDefault(defName), _ => null, }; @@ -143,6 +148,13 @@ public sealed class DefCatalog /// public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key; + /// + /// Whether this locale actually carries the key. 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. + /// + public bool HasText(string locale, string key) => TryText(locale, key, out _); + private bool TryText(string locale, string key, out string text) { var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru; @@ -164,6 +176,7 @@ public sealed class DefCatalog BodyAttributeDef => DefKind.BodyAttribute, NeedDef => DefKind.Need, NameSetDef => DefKind.NameSet, + SubjectDef => DefKind.Subject, _ => throw new ArgumentOutOfRangeException(nameof(def)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 34e259e..39bedae 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -15,6 +15,7 @@ public enum DefKind BodyAttribute, Need, NameSet, + Subject, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. @@ -64,8 +65,15 @@ public sealed class RoomDef : Def /// /// When true, a map room of this def with pupil slots becomes a roster class. /// Labs keep for lessons without forming a homeroom. + /// Homerooms are a seat count of , not a table of named slots. /// public bool Homeroom { get; init; } + + /// Thing whose × map seats is class capacity. + public string? SeatThing { get; init; } + + /// Editor default when placing a new homeroom. Vanilla classrooms are 16. + public int DefaultSeats { get; init; } } public sealed class BuildingDef : Def; diff --git a/src/HSchool.Content/MapLayout.cs b/src/HSchool.Content/MapLayout.cs index ec0ef11..3b145f9 100644 --- a/src/HSchool.Content/MapLayout.cs +++ b/src/HSchool.Content/MapLayout.cs @@ -55,6 +55,12 @@ public sealed class RoomNode /// Optional designation such as a classroom number. The def label still supplies the noun. public string? Label { get; init; } + /// + /// Homeroom seat count. Capacity is SeatThing.PupilSlots × Seats. Named + /// stay on rooms whose furnishing actually varies. + /// + public int Seats { get; init; } + public IReadOnlyList Slots { get; init; } = []; } diff --git a/src/HSchool.Content/MapValidator.cs b/src/HSchool.Content/MapValidator.cs index fe8ef2d..65542e8 100644 --- a/src/HSchool.Content/MapValidator.cs +++ b/src/HSchool.Content/MapValidator.cs @@ -64,7 +64,7 @@ public static class MapValidator 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; } @@ -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)) { 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); foreach (var fill in room.Slots) { diff --git a/src/HSchool.Content/MapView.cs b/src/HSchool.Content/MapView.cs index c4efe67..25b2338 100644 --- a/src/HSchool.Content/MapView.cs +++ b/src/HSchool.Content/MapView.cs @@ -121,23 +121,24 @@ public static class MapView string locale, RoomNode room) { - var items = new List(room.Slots.Count); - var pupilSlots = 0L; - foreach (var fill in room.Slots) + if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom && !string.IsNullOrWhiteSpace(def.SeatThing)) { - var count = SlotQuantity(fill.Count); - items.Add(new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, fill.Thing), count)); - if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0) - { - pupilSlots += (long)thing.PupilSlots * count; - } + var seats = RoomOccupancy.Quantity(room.Seats > 0 ? room.Seats : def.DefaultSeats); + return ( + [new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, def.SeatThing), seats)], + RoomOccupancy.PupilSlots(catalog, room)); } - return (items, (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue)); - } + var items = new List(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) => - count < 1 ? 1 : Math.Min(count, byte.MaxValue); + return (items, RoomOccupancy.PupilSlots(catalog, room)); + } /// /// A floor's is its designation, not a replacement name — the diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index f2447fa..0b6e3a2 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -101,6 +101,9 @@ internal static class PackPaths case "namesets": kind = DefKind.NameSet; return true; + case "subjects": + kind = DefKind.Subject; + return true; default: kind = default; return false; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 8b49719..5cda435 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -29,6 +29,11 @@ internal static class PeopleDefValidator ValidateNameSet(names); } + foreach (var subject in catalog.Subjects.Values) + { + ValidateSubject(subject, 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 1–11 with min ≤ max."); + } + + if (subject.Skills.Count == 0) + { + throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill."); + } + + var seen = new HashSet(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) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 65118dd..18706e2 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -122,6 +122,22 @@ public sealed class SkillDef : Def public IReadOnlyList 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 Skills { get; init; } = []; +} + public sealed class TraitSkillModifier { public required string Skill { get; init; } diff --git a/src/HSchool.Content/RoomOccupancy.cs b/src/HSchool.Content/RoomOccupancy.cs new file mode 100644 index 0000000..8ae77ca --- /dev/null +++ b/src/HSchool.Content/RoomOccupancy.cs @@ -0,0 +1,38 @@ +namespace HSchool.Content; + +/// +/// How many pupils a map room can host. Homerooms are a seat count of one thing; other rooms +/// still sum ThingDef.PupilSlots × fill count across named slots. +/// +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); + } +} diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 87aa25b..c0d0ccc 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -9,7 +9,7 @@ public static class RosterGenerator /// Matches SimulationOptions.DefaultStartDate so tests without a clock still sit in /// the default school year. Callers with a live clock must pass . /// - 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( DefCatalog catalog, diff --git a/src/HSchool.People/SchoolDemand.cs b/src/HSchool.People/SchoolDemand.cs index 696471b..aa45a68 100644 --- a/src/HSchool.People/SchoolDemand.cs +++ b/src/HSchool.People/SchoolDemand.cs @@ -33,7 +33,7 @@ public sealed class SchoolDemand 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)) { foreach (var position in def.Positions) @@ -67,19 +67,4 @@ public sealed class SchoolDemand 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); - } } diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 72bfd71..5231de2 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -87,6 +87,7 @@ internal sealed record CatalogResponse( IReadOnlyList Rooms, IReadOnlyList Things, IReadOnlyList NameSets, + IReadOnlyList Subjects, MapLayout DefaultMap) { public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) => @@ -97,6 +98,7 @@ internal sealed record CatalogResponse( PlaceableRooms(catalog, locale), PlaceableThings(catalog, locale), Placeable(catalog.NameSets.Values, catalog, locale), + PlaceableSubjects(catalog, locale), map); private static IReadOnlyList Placeable(IEnumerable defs, DefCatalog catalog, string locale) @@ -121,9 +123,25 @@ internal sealed record CatalogResponse( .Select(def => new RoomInfoResponse( def.DefName, catalog.Label(locale, def), + def.Homeroom, + def.SeatThing, + def.DefaultSeats, def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(), def.Positions.ToArray())) .ToArray(); + + private static IReadOnlyList 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); @@ -131,7 +149,20 @@ internal sealed record DefInfoResponse(string DefName, string Label, int PupilSl internal sealed record RoomInfoResponse( string DefName, string Label, + bool Homeroom, + string? SeatThing, + int DefaultSeats, IReadOnlyList Slots, IReadOnlyList Positions); 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 Skills); diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index 49944e3..06685bb 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -10,7 +10,7 @@ "TickRate": 20, "MaxSchools": 6, "GameMinutesPerRealSecond": 5, - "DefaultStartDate": "2012-04-03T06:00:00", + "DefaultStartDate": "2012-03-31T06:00:00", "SavesDirectory": "saves", "ModsDirectory": "mods", "SaveIntervalSeconds": 30 diff --git a/src/HSchool.Server/mods/core/defs/positions/staff.jsonc b/src/HSchool.Server/mods/core/defs/positions/staff.jsonc index 662031f..239e028 100644 --- a/src/HSchool.Server/mods/core/defs/positions/staff.jsonc +++ b/src/HSchool.Server/mods/core/defs/positions/staff.jsonc @@ -4,6 +4,5 @@ { "defName": "Teacher" }, { "defName": "Librarian" }, { "defName": "Nurse" }, - { "defName": "PETeacher" }, { "defName": "CafeteriaCook" }, ] diff --git a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc index a3d963f..48ea214 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc @@ -1,15 +1,10 @@ [ { "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, + "seatThing": "StudentDesk", + "defaultSeats": 16, + "works": ["TeachLesson"], }, { "defName": "Library", @@ -27,7 +22,6 @@ { "key": "teacherChair", "thing": "Chair" }, { "key": "computers", "thing": "Computer", "count": 12 }, ], - "positions": ["Teacher"], "works": ["TeachLesson"], }, ] diff --git a/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc b/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc index 08d51fe..7ecafb8 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc @@ -3,6 +3,5 @@ "slots": [ { "key": "benches", "thing": "Bench", "count": 4 }, ], - "positions": ["PETeacher"], "works": ["PELesson"], } diff --git a/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc new file mode 100644 index 0000000..0b2ac73 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc @@ -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 }], + }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 9640683..1686672 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -16,8 +16,8 @@ "Teacher": "Teacher", "Librarian": "Librarian", "Nurse": "Nurse", - "PETeacher": "PE teacher", "CafeteriaCook": "Cook", + "PrimarySchool": "Primary", "PrincipalOfficeWork": "Principal's work", "TeachLesson": "Teach a lesson", "WalkSchool": "Walk the school", @@ -87,6 +87,7 @@ "Toilet": "Toilet", "Social": "Social", "Slavic": "Slavic", + "Build": "Build", "Skinny": "Skinny", "Average": "Average", "Athletic": "Athletic", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 5d1e89f..3e73df3 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -16,8 +16,8 @@ "Teacher": "Учитель", "Librarian": "Библиотекарь", "Nurse": "Медсестра", - "PETeacher": "Учитель физкультуры", "CafeteriaCook": "Повар", + "PrimarySchool": "Начальные классы", "PrincipalOfficeWork": "Работа директора", "TeachLesson": "Урок", "WalkSchool": "Обход школы", @@ -87,6 +87,7 @@ "Toilet": "Туалет", "Social": "Общение", "Slavic": "Славянский", + "Build": "Телосложение", "Skinny": "Худощавое", "Average": "Обычное", "Athletic": "Атлетическое", diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc index 5101a20..2926cc0 100644 --- a/src/HSchool.Server/mods/core/maps/default.jsonc +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -52,12 +52,7 @@ "building": "main", "floor": "floor-1", "label": "101", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-102", @@ -65,12 +60,7 @@ "building": "main", "floor": "floor-1", "label": "102", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-103", @@ -78,12 +68,7 @@ "building": "main", "floor": "floor-1", "label": "103", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-104", @@ -91,12 +76,7 @@ "building": "main", "floor": "floor-1", "label": "104", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "cafeteria", @@ -127,12 +107,7 @@ "building": "main", "floor": "floor-2", "label": "201", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-202", @@ -140,12 +115,7 @@ "building": "main", "floor": "floor-2", "label": "202", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-203", @@ -153,12 +123,7 @@ "building": "main", "floor": "floor-2", "label": "203", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-204", @@ -166,12 +131,7 @@ "building": "main", "floor": "floor-2", "label": "204", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-205", @@ -179,12 +139,7 @@ "building": "main", "floor": "floor-2", "label": "205", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-206", @@ -192,12 +147,7 @@ "building": "main", "floor": "floor-2", "label": "206", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "classroom-207", @@ -205,12 +155,7 @@ "building": "main", "floor": "floor-2", "label": "207", - "slots": [ - { "key": "board", "thing": "Blackboard" }, - { "key": "teacherDesk", "thing": "Desk" }, - { "key": "teacherChair", "thing": "Chair" }, - { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, - ], + "seats": 16, }, { "id": "library", diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index afb1b48..b36bb54 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -5,7 +5,7 @@ public sealed class SimulationOptions { 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); /// Fixed simulation steps per second. public int TickRate { get; set; } = 20; diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index c5976d2..ac5d862 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -109,7 +109,7 @@ public class GameSocketTests(AppHostFixture fixture) var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-101"); Assert.Equal(16, classroom.PupilSlots); 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] diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index d45875c..84412c8 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -9,7 +9,7 @@ namespace HSchool.AppHost.Tests; [Collection(AppHostCollection.Name)] 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] public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate() @@ -159,6 +159,14 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal("yard", ru.DefaultMap.Territory?.Id); 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("Начальные классы", 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] @@ -358,14 +366,24 @@ public class SchoolApiTests(AppHostFixture fixture) IReadOnlyList Rooms, IReadOnlyList Things, IReadOnlyList NameSets, + IReadOnlyList Subjects, MapLayoutResponse DefaultMap); private sealed record DefInfoResponse(string DefName, string Label); - private sealed record RoomInfoResponse(string DefName, string Label, IReadOnlyList Slots, IReadOnlyList Positions); + private sealed record RoomInfoResponse( + string DefName, + string Label, + bool Homeroom, + string? SeatThing, + int DefaultSeats, + IReadOnlyList Slots, + IReadOnlyList Positions); 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 TerritoryResponse(string Id, string Def); diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs index 7d2f533..abc73b8 100644 --- a/tests/HSchool.Content.Tests/MapViewTests.cs +++ b/tests/HSchool.Content.Tests/MapViewTests.cs @@ -43,16 +43,9 @@ public class MapViewTests var classroom = ru.Single(node => node.Id == "classroom-101"); Assert.Equal("Класс 101", classroom.Name); - Assert.Equal( - [ - new MapViewItem("Доска", 1), - new MapViewItem("Стол", 1), - new MapViewItem("Стул", 1), - new MapViewItem("Парта", 16), - ], - classroom.Items); + Assert.Equal([new MapViewItem("Парта", 16)], classroom.Items); 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); } diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index eeaccf9..53d7299 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -23,6 +23,9 @@ public class PeopleDefTests Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"])); Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"])); 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] @@ -147,6 +150,30 @@ public class PeopleDefTests Assert.Contains("Ghost", ex.Message); } + [Fact] + public void Subject_UnknownSkill_FailsTheCatalog() + { + var ex = Assert.Throws(() => _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] public void OneWayIncompatibility_IsMutual() { diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 45692da..824acb0 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -26,24 +26,80 @@ public class VanillaCoreTests Assert.True(catalog.Rooms["Classroom"].Homeroom); Assert.False(catalog.Rooms["ComputerLab"].Homeroom); 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["Computer"].PupilSlots); Assert.Equal(0, catalog.Things["Desk"].PupilSlots); Assert.Equal(0, catalog.Things["Chair"].PupilSlots); - Assert.Contains(catalog.Rooms["Classroom"].Slots, slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); - Assert.Equal(16, catalog.Rooms["Classroom"].Slots.Single(slot => slot.Key == "studentDesks").Count); + Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); + 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); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); 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-207" && room.Label == "207"); Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B"); - Assert.Equal( - 16, - 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"); + Assert.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats); + Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots); } + + /// + /// 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. + /// + [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(); + 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 Names(IEnumerable defs) => + defs.Where(def => !def.Abstract).Select(def => def.DefName); } diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs index bb36b5b..573a02a 100644 --- a/tests/HSchool.People.Tests/Fixtures.cs +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -47,10 +47,7 @@ internal static class Fixtures Building = "main", Floor = "floor-1", Label = $"{101 + i}", - Slots = - [ - new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks }, - ], + Seats = desks, }; } @@ -72,7 +69,7 @@ internal static class Fixtures Building = "main", Floor = "floor-1", 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 }; } + 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) => RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf); diff --git a/tests/HSchool.People.Tests/RosterBrowserTests.cs b/tests/HSchool.People.Tests/RosterBrowserTests.cs index edb9c15..8cea45c 100644 --- a/tests/HSchool.People.Tests/RosterBrowserTests.cs +++ b/tests/HSchool.People.Tests/RosterBrowserTests.cs @@ -55,7 +55,7 @@ public class RosterBrowserTests [Fact] public void ParentFilter_IncludesStaffWhoAreAlsoParents() { - var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var roster = Fixtures.Generate(Fixtures.VanillaMap()); var page = RosterBrowser.Apply( roster, Fixtures.AsOf, diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index 059d563..46ea3a6 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -104,7 +104,7 @@ public class RosterGeneratorTests [Fact] 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); } @@ -122,7 +122,9 @@ public class RosterGeneratorTests Assert.Equal(11, demand.Classes.Count); Assert.Equal(11 * 16, demand.Seats.Count); 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] diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs index 8a8295a..06e182d 100644 --- a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -163,10 +163,7 @@ public class PeopleInSchoolTests Building = "main", Floor = "floor-1", Label = $"{101 + i}", - Slots = - [ - new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks }, - ], + Seats = desks, }; }