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