Merge main into review/slice-7bc-climate-wear; journal 32-37.
Integrate stage A review from main; resolve reviewed.md; fix dressRulesPanel unused import, managementPanel test, PortraitApiTests URL.
This commit is contained in:
@@ -23,6 +23,7 @@ way; this file is *how to work in them*.
|
||||
| clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 29–37; country and wardrobes are live, weather is not |
|
||||
| login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 38–39 |
|
||||
| clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 |
|
||||
| talks, opinions, fights, romance pack | [`docs/design/social.md`](docs/design/social.md) — slice 9, phases 41–47 |
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# Общение, отношения и романтический пак
|
||||
|
||||
Договорённость на срез после сессии/темпа, не текущий код.
|
||||
Люди: [`people.md`](people.md). ИИ: [`ai.md`](ai.md). Одежда (уместность как образец):
|
||||
[`inventory.md`](inventory.md). Моды: [`defs.md`](defs.md), [`foundation.md`](foundation.md).
|
||||
|
||||
У человека появляется, **с кем** он говорит и **что** о нём думают другие. Нужда «общение» уже
|
||||
есть, действие `Chat` уже есть — но это соло в коридоре. Срез делает разговор кружком, тему —
|
||||
дефом, мнение — числом, вражду — ссорой и редкой дракой. Игрок это **смотрит**. Романтика не
|
||||
ваниль: отдельный пак, который ещё и проверяет, что контентный мод на живой дороге работает.
|
||||
|
||||
Срез отвечает на вопросы «кто кому друг», «о чём болтают» и «что меняет пак». Настроения-нужды,
|
||||
приказов директора и секса нет.
|
||||
|
||||
## Что меняется в уже принятом
|
||||
|
||||
| Было | Стало | Почему |
|
||||
| --- | --- | --- |
|
||||
| `Chat` — один человек стоит в коридоре | Кружок 2–4 в одном узле, одна тема | Иначе общение не общение |
|
||||
| Семья на карточке — только родство | Плюс число мнения; вкладка «Связи» | Родители по умолчанию не ноль |
|
||||
| Конфликтов нет (`ai.md`) | Ссора и редкая драка без травм | Вражда должна быть видна, не только −40 в JSON |
|
||||
| `example` — единственный не-core пак | Рядом контентный `romance` | Скучный образец не доказывает темы, ориентацию и патч `Chat` |
|
||||
| Присутствие: id, узел, идёт/стоит | Плюс кружок (id участников + тема) | «Говорит с Машей о футболе» в локации |
|
||||
| «Правила» — только одежда | Плюс политика речи | Уместность тем — тот же жест, что дресс-код |
|
||||
|
||||
## Этап A — мнения и темы
|
||||
|
||||
### Мнение
|
||||
|
||||
У упорядоченной пары `(A → B)` одно число **−100…100**. Несимметрично: A может любить B, B —
|
||||
терпеть A. Ноль не храним. Пороги и подписи — в `BehaviorDef`, не в UI:
|
||||
|
||||
знакомые / приятели / друзья / близкие друзья на плюсе; натянуто / неприязнь / враги на минусе.
|
||||
|
||||
Семья даёт **стартовое** смещение при генерации (числа тоже в данных):
|
||||
|
||||
| Связь | Типичный старт |
|
||||
| --- | --- |
|
||||
| Родитель → ребёнок и обратно | сильно плюс |
|
||||
| Братья и сёстры | плюс слабее |
|
||||
| Партнёры в семье (уже есть на карточке) | плюс, не обязательно потолок |
|
||||
|
||||
Дальше число живёт: разговоры, дом, ссоры. Родство не переписывается.
|
||||
|
||||
**Дрейф.** Раз в рабочее утро (и skip ночи) каждое ненулевое мнение чуть сдвигается к своему
|
||||
базису: семейная связь — к стартовому семейному числу, остальные — к нулю. Без этого дружба
|
||||
первой недели вечная. Скорость — в `BehaviorDef`. Ноль снова не храним: дошли до базиса-нуля —
|
||||
запись выкидываем.
|
||||
|
||||
Хранение — **на человеке**, разреженно, в `people.json`. Писать при содержательном сдвиге
|
||||
(разговор кончился, драка, утреннее семейное, утренний дрейф), не каждый тик. Тот же шов, что у
|
||||
одежды.
|
||||
|
||||
### Темы
|
||||
|
||||
Новый вид `TopicDef`: id, теги уместности, возраст, роли, можно ли на уроке (шёпот), какой
|
||||
язык помогает (опционально), какой навык качается каплей сверх Communication.
|
||||
|
||||
Ванильные теги, не витрина:
|
||||
|
||||
- учёба, игры, еда, семья, спорт, сплетни, грубое, внешность.
|
||||
|
||||
Тема «внешность / форма» стыкует срез 7: исход смотрит уместность надетого (фаза 35). Не по
|
||||
форме — насмешка или неловкость; по форме / ярко в свободной школе — комплимент. Нет ещё
|
||||
уместности на карточке — тема живёт, модификатор ноль.
|
||||
|
||||
Уместность — **оценка**, как одежда: систему саму не заставит взять грубое на линейке с
|
||||
директором; взяли — мнение может упасть, учитель может оборвать.
|
||||
|
||||
### Политика речи
|
||||
|
||||
Вкладка «Правила», рядом с одеждой. Два комбобокса (ученики / сотрудники), не четыре как форма
|
||||
и цвет:
|
||||
|
||||
| Кого | Речь |
|
||||
| --- | --- |
|
||||
| Ученики | свободно / без грубого / только учёба |
|
||||
| Сотрудники | свободно / без грубого / только учёба |
|
||||
|
||||
«Применить» — **с завтра**, как дресс-код. Сегодняшние кружки доигрывают тему.
|
||||
|
||||
### Вкладка карточки
|
||||
|
||||
Пятая: **Связи**. Семья с числами, топ друзей и врагов, поиск по ненулевым (иначе тихая вражда
|
||||
не видна). Игрок ничего не назначает. Без романтического пака колонки симпатий нет.
|
||||
|
||||
## Этап B — разговоры
|
||||
|
||||
### Кружок
|
||||
|
||||
В одном узле собирается **2–4** человека, одна тема на всех. Пятый начинает соседний кружок,
|
||||
не влезает в этот.
|
||||
|
||||
Кого зовут: сначала друзья и одноклассники в узле, потом остальные. Врагов сами не зовут —
|
||||
столкновение может уйти в ссору. Тихоня реже инициатор, лидер чаще. Веса — поля на `TraitDef`
|
||||
и `BehaviorDef`, не фамилии в коде.
|
||||
|
||||
`Chat` перестаёт быть соло: действие занимает людей кружка. Совместимость ролей как сейчас
|
||||
(ученик и сотрудник могут говорить — учитель после урока, вопрос в коридоре).
|
||||
|
||||
Где: перемена, двор, обед, раздевалка, коридор. **Учительская** — отдельное действие
|
||||
`StaffChat` (`room: TeachersRoom`, только сотрудники): взрослые не обязаны болтать в коридоре
|
||||
с детьми, чтобы восполнить общение. На уроке — только **шёпот** (тема с тегом «можно шептать»).
|
||||
Шёпот восполняет общение слабее, **режет рост навыка урока** у участников.
|
||||
|
||||
**Вместе.** На перемене и в обед друзья тянутся в узел, где уже стоят свои, а не в случайный
|
||||
коридор. Враги не садятся в один кружок (уже) и не выбирают тот же обеденный узел, если есть
|
||||
другой. Это веса цели «досуг», не новая ходьба парой и не состояние присутствия: на карте
|
||||
видно кучку в одном узле.
|
||||
|
||||
**Телефон.** У кого в сумке или в руках есть телефон (срез 7), на перемене может быть **парный**
|
||||
чат слабее живого кружка: оба с телефоном, тема из того же каталога, общение восполняется
|
||||
меньше. Без телефона действия нет. На уроке телефоном не сидят — это шёпот голосом, не экран.
|
||||
|
||||
### Язык и красноречие
|
||||
|
||||
Отдельного навыка «красноречие» нет: это `Communication`. Языки — как сейчас.
|
||||
|
||||
Общий язык: лучший общий навык среди участников выше порога из `BehaviorDef`. Нет общего —
|
||||
кружок всё равно возможен (жесты), но сдвиг мнения почти нулевой и чаще отрицательный.
|
||||
|
||||
`Communication` чуть растёт от разговора (меньше, чем предмет за урок). Язык, на котором
|
||||
говорили, чуть растёт, если он ещё не у потолка.
|
||||
|
||||
### Шёпот
|
||||
|
||||
Если учитель **в том же кабинете**, шанс оборвать — из `BehaviorDef`, черты тише/громче
|
||||
сдвигают. Не каждый шёпот ловят. Учитель, поймавший, начинает разговор-выговор (тот же
|
||||
механизм кружка, тема «дисциплина»).
|
||||
|
||||
После урока учитель может сам взять 2–4 учеников (вопрос по предмету, похвала, выговор) —
|
||||
обязанность с весом, не приказ игрока. Сдвиг мнения ученика об учителе слегка масштабируется
|
||||
**педагогикой** учителя: сильный педагог после похвалы плюсует больше, слабый выговор злит
|
||||
сильнее. Оценок по-прежнему нет.
|
||||
|
||||
### Дом
|
||||
|
||||
Родители на карту не приходят. **Одно** семейное событие на человека за рабочее утро; skip
|
||||
ночи его применяет, как утреннюю одежду. Тема из семейного набора, сдвиг небольшой.
|
||||
Тем же утренним проходом — дрейф всех мнений к базису. Настроения-нужды нет.
|
||||
|
||||
### Черты в `core`
|
||||
|
||||
Уже крутят разговор: задира, тихоня, лидер, добрый, вспыльчивый. Добавляются две:
|
||||
|
||||
- **сплетник** — чаще берёт темы «сплетни», сильнее двигает мнение;
|
||||
- **общительный** — чаще инициатор, выше шанс большого кружка.
|
||||
|
||||
Поля на `TraitDef` (вес инициативы, сдвиг к тегам тем), не списки id в коде.
|
||||
|
||||
## Этап C — ссора и драка
|
||||
|
||||
Отдельные действия, не только −N к мнению.
|
||||
|
||||
**Ссора** — на перемене, двое (иногда трое из распавшегося кружка), общение восполняется
|
||||
криком, мнения падают сильнее обычного грубого разговора.
|
||||
|
||||
**Задира** не бросает кубик по школе каждый день: на человеке память жертвы (id), пока та в
|
||||
ростере. Смена цели — если жертва выпустилась, ушла или мнение уже на полу. Поля на черте, не
|
||||
`if (defName == "Bully")` в коде сверх проверки поля.
|
||||
|
||||
**Заступничество.** Третий в том же узле с высоким плюсом к жертве может встать в ссору на её
|
||||
стороне. Мнение к задире падает, к жертве растёт; кружок ссоры становится тройкой.
|
||||
|
||||
**Извиниться** — отдельное действие после ссоры или выговора, пока оба в одном узле. Возвращает
|
||||
часть (не всё) потерянного мнения. Задира извиняется реже (поле черты). Без этого единственный
|
||||
путь вверх — «ещё поболтать» и утренний дрейф к нулю.
|
||||
|
||||
**Драка** — редко, двор или физкультура. Короткая. Травм, медкабинета и вызова родителей нет.
|
||||
Мнения резко падают, оба в логе. Дежурный учитель или физрук в узле **обрывает** и делает
|
||||
выговор. Никого в кабинете директора насильно нет: игрока нет в приказе, отдельной очереди
|
||||
«к директору» этот срез не заводит.
|
||||
|
||||
Нет сотрудника рядом — драка кончается по длительности действия, лог всё равно пишется.
|
||||
|
||||
## Этап D — что видит игрок
|
||||
|
||||
Игрок **наблюдает**. Не мирит, не рассаживает, не запрещает дружить.
|
||||
|
||||
- **Локация** — «говорит с Машей о футболе»: кадр присутствия несёт id участников и id темы,
|
||||
клиент собирает строку через справочник имён и локаль темы. Имена по-прежнему не строкой в
|
||||
сокете. Раскладка присутствия меняется, **версия протокола +1**.
|
||||
- **Сейчас** на карточке — то же плюс история за сегодня (уже есть лог).
|
||||
- **Связи** — числа и подписи.
|
||||
- **Правила** — политика речи, с завтра.
|
||||
|
||||
Список людей мнения не несёт.
|
||||
|
||||
## Этап E — пак `romance`
|
||||
|
||||
Первый **контентный** мод. `example` не раздуваем: он образец папки. `romance` выбирается в
|
||||
create, `requires: ["core"]`, патчит `Chat`/темы (романтические темы появляются только с паком).
|
||||
|
||||
Без пака генератор не пишет ориентацию и романтические состояния. Живой школе мод не добавить —
|
||||
как все паки сейчас.
|
||||
|
||||
### Ориентация
|
||||
|
||||
Поле на человеке, только если пак в школе. Значения — данные пака (гетеро / би / гомо и веса
|
||||
генератора), не черта «ради галочки» и не хардкод. Пак без поля ориентации романтику не крутит.
|
||||
|
||||
### Симпатия и пара
|
||||
|
||||
Симпатия — односторонний ярлык поверх мнения (порог из данных пака). «Тили-тили тесто» —
|
||||
подпись и лог, не свидание.
|
||||
|
||||
| Кто | Симпатия | Пара / свидание |
|
||||
| --- | --- | --- |
|
||||
| Ученик ↔ ученик близкого возраста | да | только если **обоим 18+** |
|
||||
| Ученик → учитель | да, класс может дразнить; на карточке учителя видно | нет |
|
||||
| Учитель → ученик | **нет**, взаимности нет, осаживает разговором | нет |
|
||||
| Сотрудник ↔ сотрудник | да | да, оба взрослые |
|
||||
| Сотрудник ↔ ученик как пара | нет | нет |
|
||||
|
||||
Близкий возраст учеников: **±2 года или соседняя параллель**. К учителю — исключение из этого
|
||||
фильтра (иначе взрослый никогда не попадает).
|
||||
|
||||
Пара: только **взаимная**, только **одна**. Разрыв — событие (разговор или мнение ниже порога):
|
||||
мнение падает, симпатия может остаться. Новая пара не раньше следующего дня. Неразделённая
|
||||
симпатия живёт сколько угодно. Измену как механику не делаем: вторая симпатия просто висит.
|
||||
|
||||
Никакого сексуального контента, портретов «для романтики» и действий кроме разговора / ссоры /
|
||||
уже существующих перемещений. Детская линия — дразнилки и ярлык симпатии.
|
||||
|
||||
Пак может добавить черты вроде «влюбчивый», «ревнивый» теми же полями `TraitDef`.
|
||||
|
||||
## Поток данных
|
||||
|
||||
Мнения и кружок — состояние мира, считает `HSchool.Ai` (выбор темы, состав, исход), пишет
|
||||
симуляция. Карточка — HTTP через мейлбокс. Присутствие — сокет. Политика речи — снимок школы,
|
||||
как будущий дресс-код.
|
||||
|
||||
Детерминизм: свой поток на человека, обход по id, тема из сида кружка. Тот же сид и те же
|
||||
паки — те же первые дружбы на той же неделе (золотой отпечаток мнений — в фазе, которая
|
||||
начинает писать ненулевые пары сверх семьи).
|
||||
|
||||
## Где живёт код
|
||||
|
||||
Нового проекта нет.
|
||||
|
||||
| Что | Куда |
|
||||
| --- | --- |
|
||||
| `TopicDef`, теги, поля черт, политика речи | `HSchool.Content` + JSONC |
|
||||
| Стартовые мнения семьи, ориентация при паке | `HSchool.People` |
|
||||
| Кружок, шёпот, ссора, драка, дом-утро | `HSchool.Ai` + адаптер в `HSchool.Simulation` |
|
||||
| Кадр присутствия | `HSchool.Protocol` + `protocol.ts` + `docs/protocol.md` |
|
||||
| Вкладка, правила, лог | `HSchool.Server` + клиент |
|
||||
| Пак | `mods/romance/` рядом с `core` и `example` |
|
||||
|
||||
## Советы, которые стоит принять сразу
|
||||
|
||||
- Не заводить нужду «настроение»: мнение + общение уже две шкалы.
|
||||
- Не класть имена в кадр присутствия — только id, как с людьми сейчас.
|
||||
- Не лечить драку травмой: медкабинет тогда обязан стать больницей.
|
||||
- Не делать «ходить вместе» отдельным состоянием присутствия: достаточно одного узла.
|
||||
- `ActionDef` по-прежнему одна комната: учительская и телефон — отдельные действия, не массив комнат.
|
||||
- Не вшивать романтику в `core`: ванильная школа должна собираться без пака.
|
||||
- Не расширять `example`. Контент — другой пак.
|
||||
- Не делать учительскую взаимность «опцией мода»: пак проверяет запрет тестом.
|
||||
|
||||
## Заведомо не сейчас
|
||||
|
||||
- Настроение как нужда, приказы, вызов родителей, рассадка класса.
|
||||
- Секс, поцелуи как действие, свидания вне карты, ревность-сюжет.
|
||||
- Пара учителя и ученика, даже 18+.
|
||||
- Травмы, дневник дисциплины как журнал оценок.
|
||||
- Смена модов у живой школы.
|
||||
- Общая лента событий школы — по-прежнему лог человека.
|
||||
|
||||
## Зафиксировано этим разговором
|
||||
|
||||
| Тема | Решение |
|
||||
| --- | --- |
|
||||
| Мнение | −100…100, несимметрично, подписи по порогам, семья — старт |
|
||||
| Дрейф | Каждое рабочее утро к семейному базису или к нулю; skip применяет |
|
||||
| Хранение | Разреженно на человеке, писать при сдвиге |
|
||||
| Темы | `TopicDef` + теги включая внешность; оценка, не запрет |
|
||||
| Политика речи | Ученики/сотрудники: свободно / без грубого / только учёба; с завтра |
|
||||
| Кружок | Сразу 2–4; пятый — другой кружок |
|
||||
| Кого зовут | Друзья и класс в узле, врагов не зовут |
|
||||
| Вместе | Друзья на перемене/обеде тянутся в один узел |
|
||||
| Где | Перемена, двор, обед, после урока, учительская; на уроке — шёпот |
|
||||
| Телефон | Парный чат на перемене, оба с телефоном, слабее живого |
|
||||
| Шёпот | Режет урок; учитель в кабинете — шанс оборвать |
|
||||
| Язык | Нет общего — слабый разговор, не отказ |
|
||||
| Красноречие | `Communication`; растёт от разговора |
|
||||
| Педагогика | Масштабирует мнение ученика об учителе после разговора |
|
||||
| Игрок | Только смотрит |
|
||||
| Экран | Вкладка «Связи»; локация из id в присутствии; протокол +1 |
|
||||
| Ссора / драка | Действия; жертва задиры помнится; заступник; извинение; без травм |
|
||||
| Дом | Одно семейное событие на рабочее утро; skip применяет |
|
||||
| Настроение | Нет |
|
||||
| Черты core | + сплетник, общительный |
|
||||
| Романтика | Пак `romance`, патчит темы/`Chat` |
|
||||
| Ориентация | Только с паком, поле и веса в данных |
|
||||
| Симпатия учеников | Близкий возраст ±2 / соседняя параллель |
|
||||
| Симпатия к учителю | Можно, видно, учитель осаждает, взаимности нет |
|
||||
| Пара | Оба 18+, взаимно, одна; разрыв — событие; сотрудник↔ученик нет |
|
||||
| Сотрудники | Симпатия и пара между собой — да |
|
||||
@@ -12,24 +12,24 @@
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим
|
||||
- [ ] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников.
|
||||
- [x] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим
|
||||
- [x] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников.
|
||||
Значения — как в [`../design/inventory.md`](../design/inventory.md)
|
||||
- [ ] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что
|
||||
- [x] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что
|
||||
вступит завтра
|
||||
- [ ] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила —
|
||||
- [x] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила —
|
||||
`400`, не тишина
|
||||
- [ ] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него
|
||||
- [ ] Строки через `t(...)`, обе локали
|
||||
- [ ] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом
|
||||
- [x] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него
|
||||
- [x] Строки через `t(...)`, обе локали
|
||||
- [x] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] GET правил на новой школе — дефолт обычная / без яркого на обе роли
|
||||
- [ ] POST «строгая» сегодня не меняет одежду до следующего рабочего утра
|
||||
- [ ] После утра на теле учеников нет яркого, если отложили «без яркого»
|
||||
- [ ] Неизвестный код политики — `400`
|
||||
- [ ] Вкладка не ломает наём и сетку расписания
|
||||
- [x] GET правил на новой школе — дефолт обычная / без яркого на обе роли
|
||||
- [x] POST «строгая» сегодня не меняет одежду до следующего рабочего утра
|
||||
- [x] После утра на теле учеников нет яркого, если отложили «без яркого»
|
||||
- [x] Неизвестный код политики — `400`
|
||||
- [x] Вкладка не ломает наём и сетку расписания
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Фаза 41. Мнения и вкладка «Связи»
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 8](08-people-browser.md)
|
||||
- [Фаза 36](36-person-card-tabs.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
У пары появляется число. Семья больше не «просто родственники». Карточка умеет это показать,
|
||||
даже пока кружков ещё нет.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] Мнение A→B: −100…100, несимметрично, ноль не хранить
|
||||
- [ ] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам
|
||||
- [ ] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных
|
||||
- [ ] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю;
|
||||
дошли до нуля — запись выкинуть
|
||||
- [ ] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи
|
||||
после первой загрузки-переписи
|
||||
- [ ] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым
|
||||
- [ ] Игрок с вкладки ничего не меняет
|
||||
- [ ] `docs/protocol.md` — HTTP карточки
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи
|
||||
- [ ] A→B и B→A могут различаться
|
||||
- [ ] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось)
|
||||
ближе к нулю, семейное — к старту, не к нулю
|
||||
- [ ] Карточка без отдельного запроса «всех пар школы»: только этот человек
|
||||
- [ ] Клиентский тест: вкладка «Связи» не монтирует лог
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- Открыть ученика, на «Связях» родители с подписью «близкие» или соседней, одноклассник без строки
|
||||
- `dotnet test` и `npm test` / `build` проходят
|
||||
|
||||
## Стоп
|
||||
|
||||
Не собирать кружки. Не бампить сокет. Не заводить романтику.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Фаза 42. Темы и кружки
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 41](41-opinions.md)
|
||||
- [Фаза 21](21-decisions.md)
|
||||
- [Фаза 31](31-dress-generator.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
`Chat` перестаёт быть соло. В узле говорят 2–4 человека об одной теме. Язык и
|
||||
`Communication` влияют на исход.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] `TopicDef`: теги, возраст, роли, шёпот-на-уроке, опциональный язык
|
||||
- [ ] Ванильный набор тем (учёба, игры, еда, семья, спорт, сплетни, грубое, внешность) в `core`.
|
||||
Тема внешности учитывает уместность одежды, если сервер её уже прислал; иначе модификатор 0
|
||||
- [ ] Кружок 2–4 в одном узле; пятый не влезает
|
||||
- [ ] Кого зовут: друзья и одноклассники в узле, врагов не зовут; тихоня/лидер — поля черты
|
||||
- [ ] На перемене и в обед друзья тянутся в узел своих; враги не выбирают тот же обеденный узел,
|
||||
если есть другой
|
||||
- [ ] `StaffChat` в `TeachersRoom`, только сотрудники
|
||||
- [ ] Парный телефонный чат на перемене: оба несут `Phone`, слабее живого кружка; на уроке нет
|
||||
- [ ] Нет общего языка — кружок можно, сдвиг мнения слабый и чаще вниз
|
||||
- [ ] `Communication` растёт от разговора меньше, чем предмет за урок; язык разговора — капля,
|
||||
если не у потолка
|
||||
- [ ] Черты **сплетник** и **общительный** в `core` с полями, не списками в коде
|
||||
- [ ] Исход пишет мнения всех пар кружка и строку лога
|
||||
- [ ] На уроке обычный `Chat` не стартует (шёпот — фаза 43)
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Двое свободных друзей в коридоре на перемене собирают кружок; враги — нет
|
||||
- [ ] Пятый в узле не входит в тот же кружок
|
||||
- [ ] Сотрудники в учительской могут `StaffChat`; ученик это действие не берёт
|
||||
- [ ] Двое с телефоном на перемене могут парный чат; без телефона — нет
|
||||
- [ ] Без общего языка мнение почти не растёт при том же `Communication`
|
||||
- [ ] Высокий `Communication` сильнее двигает число, чем низкий, на одной теме
|
||||
- [ ] Каталог без `TopicDef` не стартует школу с кружками — или падает загрузка, не молча соло-Chat
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- На перемене в коридоре двое «разговаривают», на карточке мнение сдвинулось, в логе тема
|
||||
- `dotnet test` проходит
|
||||
|
||||
## Стоп
|
||||
|
||||
Не шёпот на уроке. Не драка. Не менять кадр присутствия (локация может ещё писать «Разговор»).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Фаза 43. Шёпот и учитель
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 42](42-talk-circles.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
На уроке можно шептаться — ценой учёбы. Учитель иногда слышит. После урока учитель сам
|
||||
говорит с учениками.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса
|
||||
- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`)
|
||||
- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 %
|
||||
- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у
|
||||
строгого разговора
|
||||
- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели
|
||||
- [ ] Сдвиг мнения ученика об учителе масштабируется педагогикой (поле навыка, не фамилия)
|
||||
- [ ] Лог: «шептались», «учитель оборвал»
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка
|
||||
- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается
|
||||
- [ ] Без учителя в узле шёпот не обрывается этим правилом
|
||||
- [ ] Учитель с высокой педагогикой после похвалы сильнее плюсует мнение, чем с низкой
|
||||
- [ ] Обычный не-шёпотный `Chat` на уроке не стартует
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- На математике двое шепчутся, иногда учитель обрывает, в логе это видно
|
||||
- `dotnet test` проходит
|
||||
|
||||
## Стоп
|
||||
|
||||
Не драка. Не политика речи в UI. Не протокол.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Фаза 44. Ссора и драка
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 42](42-talk-circles.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
Вражда — не только минус в JSON. На перемене ссорятся, на дворе или физкультуре редко дерутся.
|
||||
Травм нет.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора
|
||||
- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта
|
||||
- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName
|
||||
- [ ] Задира держит id жертвы, пока та в ростере; не меняет цель каждый день
|
||||
- [ ] Третий с высоким плюсом к жертве может вступить в ссору на её стороне
|
||||
- [ ] Действие «извиниться» после ссоры/выговора в одном узле возвращает часть мнения
|
||||
- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается
|
||||
- [ ] Лог обоих; кабинет директора не заводим
|
||||
- [ ] Игрок не приказывает разнять
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей
|
||||
- [ ] Задира два дня подряд целится в того же человека, если тот ещё в школе
|
||||
- [ ] Извинение поднимает мнение, но не выше, чем до ссоры
|
||||
- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется
|
||||
- [ ] Выговор после обрыва есть, если в узле был сотрудник
|
||||
- [ ] Skip пустого времени не оставляет «вечную драку» в состоянии
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- Задира с врагом на дворе: в логе ссора или драка, мнения ниже, оба живы и на ногах
|
||||
- `dotnet test` проходит
|
||||
|
||||
## Стоп
|
||||
|
||||
Не вызов родителей. Не травмы. Не романтика.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Фаза 45. Кружок в присутствии
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 42](42-talk-circles.md)
|
||||
- [Фаза 19](19-presence-screen.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
В панели локации видно, с кем и о чём говорят, без открытия карточки.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] Кадр присутствия: у человека, который в кружке, список id участников и id темы.
|
||||
Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали
|
||||
- [ ] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите
|
||||
- [ ] Не в кружке — пустой список, как сейчас только id/узел/состояние
|
||||
- [ ] Вкладка «Сейчас» на карточке согласована с тем же составом
|
||||
- [ ] Старый клиент отваливается Hello, как обычно
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах
|
||||
- [ ] Имя человека не встречается в кадре присутствия
|
||||
- [ ] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- Клик по коридору на перемене: в «персонажах» видно, кто с кем говорит
|
||||
- `dotnet test` и `npm test` / `build` проходят
|
||||
|
||||
## Стоп
|
||||
|
||||
Не политика речи. Не пак.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Фаза 46. Политика речи и дом
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 42](42-talk-circles.md)
|
||||
- [Фаза 37](37-school-rules.md)
|
||||
- [Фаза 34](34-wear-home.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
Школа задаёт, какие темы уместны — тем же жестом, что дресс-код. Родители двигают мнение утром,
|
||||
не приходя на карту.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба
|
||||
- [ ] В силу с завтра; API сегодня не переодевает темы текущих кружков
|
||||
- [ ] Система не стартует кружок с темой вне политики; начатый вчера доигрывает
|
||||
- [ ] Одно семейное событие на человека за рабочее утро; skip ночи применяет
|
||||
- [ ] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора
|
||||
- [ ] `docs/protocol.md` — HTTP правил речи
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] POST «только учёба» сегодня не мешает текущему кружку «игры»
|
||||
- [ ] На следующее рабочее утро грубая тема у учеников не стартует
|
||||
- [ ] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с
|
||||
родителем сдвинуто, брошенная дружба ближе к нулю
|
||||
- [ ] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра»
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- Сменить политику, пережить ночь кнопкой, на перемене нет грубых кружков у учеников
|
||||
- `dotnet test` и `npm test` / `build` проходят
|
||||
|
||||
## Стоп
|
||||
|
||||
Не романтика. Не вызов родителей в школу.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Фаза 47. Пак romance
|
||||
|
||||
## Зависимости
|
||||
|
||||
- [Фаза 42](42-talk-circles.md)
|
||||
- [Фаза 23](23-example-pack.md)
|
||||
- [Фаза 41](41-opinions.md)
|
||||
|
||||
## Зачем
|
||||
|
||||
Контентный мод, не скучный образец. Ваниль без романтики. Пак добавляет ориентацию, симпатию,
|
||||
пару 18+ и романтические темы патчем.
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные,
|
||||
черты влюбчивый/ревнивый полями, патч тем и при необходимости `Chat`
|
||||
- [ ] Без пака в create ориентации и романтических состояний нет; ванильный каталог без
|
||||
романтических тем
|
||||
- [ ] Симпатия учеников: ±2 года или соседняя параллель
|
||||
- [ ] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда;
|
||||
осадить — разговор; пары сотрудник↔ученик нет
|
||||
- [ ] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня
|
||||
- [ ] Сотрудники между собой — симпатия и пара можно
|
||||
- [ ] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык
|
||||
- [ ] Тесты хоста: школа с паком и без; запрет учительской взаимности
|
||||
- [ ] `example` не трогать
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] `GET /api/catalog` без `romance` не отдаёт романтические темы
|
||||
- [ ] Create с паком пишет ориентацию; без пака поля нет
|
||||
- [ ] Двое 16-летних не получают статус пары при любом мнении
|
||||
- [ ] Учитель не получает романтическое состояние на ученика
|
||||
- [ ] Первоклассник не получает симпатию к одиннадцатому (возраст)
|
||||
- [ ] Школа с `example` и без `romance` ведёт себя как ваниль в части тем
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
- Создать школу с галкой пака, увидеть симпатию на «Связях» и тему пака в логе; снять галку на
|
||||
новой школе — этого нет
|
||||
- `dotnet test` проходит
|
||||
|
||||
## Стоп
|
||||
|
||||
Не секс. Не пара учителя и ученика. Не раздувать `example`. Не смена модов у живой школы.
|
||||
+41
-1
@@ -149,7 +149,7 @@
|
||||
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
|
||||
| [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура |
|
||||
| [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня |
|
||||
| [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра |
|
||||
| [37. Правила школы](37-school-rules.md) | ✅ | Вкладка в «Управлении», четыре комбобокса, с завтра |
|
||||
|
||||
34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды,
|
||||
история — после 35; 37 стоит на 35 и 36.
|
||||
@@ -182,3 +182,43 @@
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [40. Темп часов](40-clock-tempo.md) | ⬜ | ×1 = минута/с, кнопки ×½ ×1 ×2 ×5 ×10, страйд тяжёлых систем |
|
||||
|
||||
## Срез 9. Общение и романтический пак
|
||||
|
||||
Дизайн: [`../design/social.md`](../design/social.md).
|
||||
|
||||
Разговор становится кружком, мнение — числом, вражда — ссорой. Романтика не ваниль: отдельный
|
||||
пак. Срез можно остановить после этапа B — школа болтает, даже если драк и пака ещё нет.
|
||||
|
||||
**Этап A — мнения.** Число и вкладка.
|
||||
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [41. Мнения и «Связи»](41-opinions.md) | ⬜ | −100…100, семья-старт, вкладка карточки |
|
||||
|
||||
**Этап B — разговоры.** Кружок, язык, шёпот.
|
||||
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, учительская, телефон, вместе |
|
||||
| [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка |
|
||||
|
||||
42 стоит на 41; 43 — на 42.
|
||||
|
||||
**Этап C — конфликт и экран.**
|
||||
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [44. Ссора и драка](44-quarrel-fight.md) | ⬜ | Жертва задиры, заступник, извинение, двор/физкультура |
|
||||
| [45. Кружок в присутствии](45-presence-talk.md) | ⬜ | Id участников и темы в кадре, протокол +1 |
|
||||
|
||||
44 и 45 стоят на 42, можно параллельно.
|
||||
|
||||
**Этап D — правила, дом, пак.**
|
||||
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [46. Политика речи и дом](46-speech-home.md) | ⬜ | Комбобоксы речи с завтра; утреннее семейное событие |
|
||||
| [47. Пак romance](47-romance-pack.md) | ⬜ | Ориентация, симпатия, пара 18+, патч тем |
|
||||
|
||||
46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать.
|
||||
|
||||
+78
-28
@@ -256,50 +256,100 @@
|
||||
|
||||
- `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`.
|
||||
Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5.
|
||||
- Срез 7, фаза 37 — в работе на момент проверки B–C; этап A проверяет соседний ревьюер.
|
||||
|
||||
## Срез 7. Этап B–C — климат и жизнь одежды (без 37)
|
||||
## Срез 7. Этап A — данные и гардероб
|
||||
|
||||
- **Фазы:** 32–36
|
||||
- **Проверен на:** `e461a9f`, 2026-08-20
|
||||
- **Пути:** `src/HSchool.Simulation/{WeatherSampler,WarmthDecay,PlaceClimate,ApparelDresser,ApparelWear,MorningDress,PersonLogBrowser}.cs`,
|
||||
`src/HSchool.People/LockerAssigner.cs`, `src/HSchool.Content/{Appropriateness,ClimatePreset,DressRules}.cs`,
|
||||
`src/HSchool.Ai/ApparelGoals.cs`, `src/HSchool.Protocol`, `src/HSchool.Server/Api/{SchoolEndpoints,PeopleModels}.cs`,
|
||||
`src/HSchool.Server/Game/PersonCardReader.cs`, `src/HSchool.Client/src/{ui/personCard.ts,format/weather.ts,net/api.ts}`,
|
||||
`docs/protocol.md`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.People.Tests/LockerAssignerTests.cs`,
|
||||
`tests/HSchool.Content.Tests/{ClimatePresetTests,AppropriatenessTests,ChangingRoomMapTests}.cs`,
|
||||
`tests/HSchool.AppHost.Tests/{PersonLogApiTests,DressRulesApiTests}.cs`, `tests/HSchool.Protocol.Tests`,
|
||||
`src/HSchool.Client/src/ui/personCard.test.ts`
|
||||
- **Итог:** фазы 32–36 подтверждены; дописано 5 тестов; исправлены 2 бага переодевания; фаза 37 в работе
|
||||
- **Фазы:** 29–31
|
||||
- **Проверен на:** `d5547b7`, 2026-08-20
|
||||
- **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`,
|
||||
`src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`,
|
||||
`src/HSchool.Server/{Game/SchoolStore.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`,
|
||||
`src/HSchool.Client/src/ui/{createSchoolDialog.ts,personCard.ts}`,
|
||||
`tests/HSchool.Content.Tests/{CountryDefTests,ApparelDefTests,VanillaCoreTests}.cs`,
|
||||
`tests/HSchool.People.Tests/{DressGeneratorTests,GoldenRosterTests}.cs`,
|
||||
`tests/HSchool.AppHost.Tests/{SchoolApiTests,InventoryApiTests,GoldenSaveTests,ServiceabilityTests}.cs`,
|
||||
`src/HSchool.Client/src/ui/createSchoolDialog.test.ts`
|
||||
- **Итог:** все 17 тестов из списков фаз на месте; исправлен дубликат `peopleTabOverview` в
|
||||
`strings.ts` (ломал `npm run build`); одно замечание рядом про вкладки карточки
|
||||
|
||||
Что подтверждено:
|
||||
|
||||
- Погода детерминирована от пресета, сида и времени; skip выставляет утро; кадр часов v8 (27 байт) в
|
||||
`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`; клиент рисует температуру с wire, не из месяца.
|
||||
- `Warmth` декейит на морозе, дома восстанавливается; черты `HeatLoving`/`ColdLoving` несовместимы.
|
||||
- Техник держит комфорт; двор = улица, кабинет теплее двора без техника; две раздевалки на ванили;
|
||||
шкафчики по полу/старшинству, пересчёт на 1 сентября.
|
||||
- Износ только на кампусе, skip/утро заменяют ветхое; утренняя выдача пишет `apparel-replaced`.
|
||||
- Уместность: утро, PE, погода, правила «с завтра» через API; карточка — вкладки и HTTP-лог с 6:00.
|
||||
- `HSchool.Simulation` без ASP.NET; `dotnet test` и Vitest (`personCard`, `weather`, `protocol`) зелёные.
|
||||
- **29:** `CountryDef`/`ClimatePresetDef`; `namesets/` убран; ваниль `Russia` с двумя пресетами;
|
||||
каталог отдаёт `countries`; create принимает `countryId`, `unknown-country` на чужую страну;
|
||||
пресет от сида (`CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset`); `nameSetId: Slavic`
|
||||
поднимается через `UpgradeOlderSave` (`GoldenSaveTests`, `legacy-no-native/`); формат без
|
||||
страны не стартует (`ServiceabilityTests`); диалог — страны и языки (`createSchoolDialog.test.ts`);
|
||||
золотой ростер обновлён (`GoldenRosterTests`).
|
||||
- **30:** поля `ThingDef`/`ColorDef`, слои как роли, мебель без `layers`; ванильная палитра и
|
||||
одежда в `VanillaCoreTests`; локали ru/en (`EveryVanillaLabel_ExistsInBothLocales`); учебник
|
||||
один def без `subject` на def.
|
||||
- **31:** `InventoryItem` с `def`/`color`/`condition`/`location`/`subject`; `DressGenerator`
|
||||
детерминирован (`DressGeneratorTests`, `InventoryApiTests.SameSeed_YieldsTheSameShirt`); слои
|
||||
без пересечения, платье на два слоя; пол/яркость/учебник/родители/соискатели/масса/Hauling;
|
||||
карточка отдаёт worn+carried, список — нет (`InventoryApiTests`); старый `people.json` без
|
||||
items одевается на reload (`PeopleJsonWithoutInventory_IsDressedOnReloadAndRewritten`);
|
||||
`HSchool.People`/`HSchool.Content` без ASP.NET; протокол не тронут.
|
||||
|
||||
Дописано:
|
||||
|
||||
- (нет новых тестов — списки фаз закрыты существующими)
|
||||
|
||||
Исправлено:
|
||||
|
||||
- Дубликат ключа `peopleTabOverview` в `strings.ts` (ru/en): vitest не ловит TS2300/TS1117,
|
||||
`npm run build` падал; ключ перенесён, `peopleTabPortrait` рядом с остальными вкладками.
|
||||
- Повтор `using HSchool.Content` в `SchoolStore.cs` (CS0105).
|
||||
|
||||
Замечено рядом:
|
||||
|
||||
- Карточка уже с вкладками (`personCard.ts`, фаза 31 обещала один свиток — позже по
|
||||
`design/inventory.md`); портрет/SwarmUI-строки в `strings.ts` — не этап A.
|
||||
- `ResolveClimatePreset` в `SchoolWorker` при reload без сохранённого пресета берёт первый
|
||||
пресет страны (`schoolSeed: 0`, `rollIfOmitted: false`) — по дизайну для legacy; create
|
||||
кидает пресет из `GameLoopService` до spawn.
|
||||
|
||||
## Срез 7. Этап B–C — климат, жизнь одежды и правила
|
||||
|
||||
- **Фазы:** 32–37
|
||||
- **Проверен на:** `<commit>`, 2026-08-20
|
||||
- **Пути:** `src/HSchool.Simulation/{WeatherSampler,WarmthDecay,PlaceClimate,ApparelDresser,ApparelWear,MorningDress,PersonLogBrowser}.cs`,
|
||||
`src/HSchool.People/LockerAssigner.cs`, `src/HSchool.Content/{Appropriateness,ClimatePreset,DressRules}.cs`,
|
||||
`src/HSchool.Ai/ApparelGoals.cs`, `src/HSchool.Protocol`, `src/HSchool.Server/Api/{SchoolEndpoints,DressRulesModels}.cs`,
|
||||
`src/HSchool.Server/Game/{PersonCardReader,SchoolWorker}.cs`, `src/HSchool.Client/src/{ui/dressRulesPanel.ts,ui/managementPanel.ts,format/weather.ts,net/api.ts}`,
|
||||
`docs/protocol.md`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.People.Tests/LockerAssignerTests.cs`,
|
||||
`tests/HSchool.Content.Tests/{ClimatePresetTests,AppropriatenessTests,ChangingRoomMapTests}.cs`,
|
||||
`tests/HSchool.AppHost.Tests/{PersonLogApiTests,DressRulesApiTests}.cs`, `tests/HSchool.Protocol.Tests`,
|
||||
`src/HSchool.Client/src/ui/{personCard,dressRulesPanel,managementPanel}.test.ts`
|
||||
- **Итог:** фазы 32–37 подтверждены; дописано 5 тестов симуляции; исправлены 2 бага переодевания и 2
|
||||
регрессии тестов (не протокол)
|
||||
|
||||
Что подтверждено:
|
||||
|
||||
- **32–36:** погода детерминирована; skip выставляет утро; кадр часов v8 (27 байт) в трёх местах;
|
||||
`Warmth` и техник; шкафчики; износ и утренняя замена; уместность и PE; вкладки карточки и HTTP-лог.
|
||||
- **37:** вкладка «Правила» в «Управлении»; четыре комбобокса; GET/POST `dress-rules` с pending;
|
||||
`unknown-form`/`unknown-color` → `400`; правило с завтра; `ColorAllowed` для `whiteTopBlackBottom`;
|
||||
`dressRulesPanel.test.ts` — combobox без API до Apply, ошибка текстом; `ManagementPanel` не ломает штат
|
||||
и сетку.
|
||||
|
||||
Дописано (32–36, прошлый проход):
|
||||
|
||||
- `AppropriatenessSimulationTests.MorningDress_StagesPeKitForTodaysLesson`
|
||||
- `AppropriatenessSimulationTests.PeBreak_CompletesChangeBeforeLesson`
|
||||
- `AppropriatenessSimulationTests.PeLesson_WearsPeThenReturnsToEveryday`
|
||||
- `AppropriatenessSimulationTests.AfterPeBreak_ModeExpectsEveryday`
|
||||
- `PresenceTests.PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak` (переименован с ожидания зала на перемене)
|
||||
- `PresenceTests.PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak`
|
||||
|
||||
Исправлено:
|
||||
|
||||
- `PresenceSystem`: ходьба по duty больше не обрывает активный `ChangeClothes` — форма успевает надеться до
|
||||
звонка.
|
||||
- `ApparelPresence.ModeAt`: после перемены following PE режим «everyday», иначе `PeForbidden` не
|
||||
поднимался и форму не снимали.
|
||||
- `PresenceSystem`: ходьба по duty не обрывает активный `ChangeClothes`.
|
||||
- `ApparelPresence.ModeAt`: после перемены following PE — режим everyday.
|
||||
- `managementPanel.test.ts`: панель штата скрывается через `hidden`, не удаляется из DOM.
|
||||
- `PortraitApiTests.GetSwarmUiSettings_ReturnsPresets`: URL `/api/settings/swarmui` (CS1503).
|
||||
- `dressRulesPanel.ts`: неиспользуемый импорт `clear` — TS6133 на `npm run build`.
|
||||
|
||||
Замечено рядом:
|
||||
|
||||
- Фаза 37 (вкладка «Правила», UI смены дресс-кода) — в работе, не проверялась.
|
||||
- `PhysicalEducationBeforeLesson_…` в `PresenceTests` не гоняет полный цикл «зал с формой» — это в
|
||||
- `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в
|
||||
`AppropriatenessSimulationTests.PeLesson_…`.
|
||||
- Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала.
|
||||
|
||||
+20
-1
@@ -389,13 +389,32 @@ is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `ki
|
||||
"kind": "avatar",
|
||||
"positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …",
|
||||
"negative": "(low quality, worst quality:1.4), …",
|
||||
"promptExtra": null
|
||||
"promptExtra": null,
|
||||
"presetId": "default",
|
||||
"presetLabel": "Default"
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
|
||||
so the client can disable generate buttons and show reachability without trying POST first.
|
||||
|
||||
### `GET /api/settings/swarmui`
|
||||
|
||||
Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler,
|
||||
LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets.
|
||||
Portrait generation resolves the preset from the person's age before building the prompt.
|
||||
|
||||
### `PUT /api/settings/swarmui`
|
||||
|
||||
Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return
|
||||
`400` `invalid-body`. Changes apply immediately to new portrait generations.
|
||||
|
||||
### `GET /api/settings/swarmui/discovery`
|
||||
|
||||
When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns
|
||||
`{ connected, models, loras, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI
|
||||
is off or unreachable, `connected` is false and the lists are empty.
|
||||
|
||||
### `GET /api/schools/{id}/dress-rules`
|
||||
|
||||
Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`.
|
||||
|
||||
@@ -11,6 +11,44 @@ const ru = {
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
createSchool: 'Создать школу',
|
||||
settings: 'Настройки',
|
||||
save: 'Сохранить',
|
||||
settingsTitle: 'SwarmUI — пресеты портретов',
|
||||
settingsActivePreset: 'Пресет по умолчанию',
|
||||
settingsEditPreset: 'Редактируемый пресет',
|
||||
settingsAddPreset: 'Добавить пресет',
|
||||
settingsPresetLabel: 'Название пресета',
|
||||
settingsModel: 'Модель',
|
||||
settingsSteps: 'Steps',
|
||||
settingsCfg: 'CFG scale',
|
||||
settingsClipSkip: 'CLIP skip',
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Базовый positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Добавить LoRA',
|
||||
settingsAvatarPreset: 'Аватар',
|
||||
settingsCustomPreset: 'Свой промпт (размеры)',
|
||||
settingsFullBodyPreset: 'В полный рост',
|
||||
settingsWidth: 'Ширина',
|
||||
settingsHeight: 'Высота',
|
||||
settingsKindPositive: 'Positive для вида',
|
||||
settingsAgeRules: 'Правила по возрасту',
|
||||
settingsAgeMin: 'От',
|
||||
settingsAgeMax: 'До',
|
||||
settingsAgePreset: 'Пресет',
|
||||
settingsAddAgeRule: 'Добавить правило',
|
||||
settingsRemoveRule: 'Удалить',
|
||||
settingsRefreshLists: 'Обновить списки из SwarmUI',
|
||||
settingsSwarmReady: 'SwarmUI подключён — списки моделей и семплеров доступны.',
|
||||
settingsSwarmOffline: 'SwarmUI недоступен — поля можно заполнить вручную.',
|
||||
settingsSwarmNotConfigured: 'SwarmUI не настроен на сервере (SwarmUi:BaseUrl).',
|
||||
settingsLoadFailed: 'Не удалось загрузить настройки.',
|
||||
settingsSaveFailed: 'Не удалось сохранить настройки.',
|
||||
settingsDiscoveryFailed: 'Не удалось получить списки из SwarmUI.',
|
||||
emptySchools: 'Пока ни одной школы. Создайте первую.',
|
||||
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
|
||||
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
|
||||
@@ -140,6 +178,7 @@ const ru = {
|
||||
peopleTabApparel: 'Одежда',
|
||||
peopleTabCarry: 'Ноша',
|
||||
peopleTabNow: 'Сейчас',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peopleAtHome: 'дома',
|
||||
peopleApparelFit: 'Уместность: {value}',
|
||||
peopleLockerYes: 'Есть шкафчик',
|
||||
@@ -152,8 +191,6 @@ const ru = {
|
||||
peopleLogEvent: 'Событие',
|
||||
peopleLogEmpty: 'Сегодня записей нет.',
|
||||
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
peoplePortraitCustom: 'Свой промпт',
|
||||
@@ -178,6 +215,7 @@ const ru = {
|
||||
peoplePortraitPromptNegative: 'Negative',
|
||||
peoplePortraitPromptLoading: 'Сборка промпта…',
|
||||
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
|
||||
peoplePortraitPromptPreset: 'Пресет: {label}',
|
||||
|
||||
modeOverview: 'Обзор',
|
||||
modeManage: 'Управление',
|
||||
@@ -232,6 +270,31 @@ const ru = {
|
||||
staffErrorAssigned: 'Этот предмет уже назначен.',
|
||||
staffErrorSubject: 'Такого предмета нет.',
|
||||
|
||||
manageTabStaff: 'Штат',
|
||||
manageTabRules: 'Правила',
|
||||
rulesCurrentTitle: 'Сегодня',
|
||||
rulesPendingTitle: 'С завтра',
|
||||
rulesPendingHint: 'Изменения вступают в силу на следующее рабочее утро.',
|
||||
rulesStudentsRole: 'Ученики',
|
||||
rulesStaffRole: 'Сотрудники',
|
||||
rulesStudentsForm: 'Форма учеников',
|
||||
rulesStudentsColor: 'Цвет учеников',
|
||||
rulesStaffForm: 'Форма сотрудников',
|
||||
rulesStaffColor: 'Цвет сотрудников',
|
||||
rulesRoleSummary: '{role}: {form}, {color}',
|
||||
rulesApply: 'Применить',
|
||||
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
|
||||
rulesSaveFailed: 'Не удалось сохранить правила.',
|
||||
rulesPickHint: 'Правила действуют на всю школу.',
|
||||
dressFormRegular: 'обычная',
|
||||
dressFormShort: 'короткая',
|
||||
dressFormStrict: 'строгая',
|
||||
dressColorFree: 'свободно',
|
||||
dressColorNoBright: 'без яркого',
|
||||
dressColorWhiteTopBlackBottom: 'белый верх, чёрный низ',
|
||||
dressErrorUnknownForm: 'Такой формы нет.',
|
||||
dressErrorUnknownColor: 'Такой цветовой политики нет.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
@@ -277,6 +340,44 @@ const en: Messages = {
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
createSchool: 'Create school',
|
||||
settings: 'Settings',
|
||||
save: 'Save',
|
||||
settingsTitle: 'SwarmUI portrait presets',
|
||||
settingsActivePreset: 'Default preset',
|
||||
settingsEditPreset: 'Preset to edit',
|
||||
settingsAddPreset: 'Add preset',
|
||||
settingsPresetLabel: 'Preset name',
|
||||
settingsModel: 'Model',
|
||||
settingsSteps: 'Steps',
|
||||
settingsCfg: 'CFG scale',
|
||||
settingsClipSkip: 'CLIP skip',
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Base positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Add LoRA',
|
||||
settingsAvatarPreset: 'Avatar',
|
||||
settingsCustomPreset: 'Custom prompt (size)',
|
||||
settingsFullBodyPreset: 'Full body',
|
||||
settingsWidth: 'Width',
|
||||
settingsHeight: 'Height',
|
||||
settingsKindPositive: 'Kind positive',
|
||||
settingsAgeRules: 'Age rules',
|
||||
settingsAgeMin: 'From',
|
||||
settingsAgeMax: 'To',
|
||||
settingsAgePreset: 'Preset',
|
||||
settingsAddAgeRule: 'Add rule',
|
||||
settingsRemoveRule: 'Remove',
|
||||
settingsRefreshLists: 'Refresh lists from SwarmUI',
|
||||
settingsSwarmReady: 'SwarmUI connected — model and sampler lists are available.',
|
||||
settingsSwarmOffline: 'SwarmUI unreachable — you can still edit fields manually.',
|
||||
settingsSwarmNotConfigured: 'SwarmUI is not configured on the server (SwarmUi:BaseUrl).',
|
||||
settingsLoadFailed: 'Could not load settings.',
|
||||
settingsSaveFailed: 'Could not save settings.',
|
||||
settingsDiscoveryFailed: 'Could not fetch lists from SwarmUI.',
|
||||
emptySchools: 'No schools yet. Create the first one.',
|
||||
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
|
||||
createDisabledTitle: 'Delete a school to create a new one',
|
||||
@@ -406,6 +507,7 @@ const en: Messages = {
|
||||
peopleTabApparel: 'Clothes',
|
||||
peopleTabCarry: 'Carried',
|
||||
peopleTabNow: 'Now',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peopleAtHome: 'home',
|
||||
peopleApparelFit: 'Fit: {value}',
|
||||
peopleLockerYes: 'Has a locker',
|
||||
@@ -418,8 +520,6 @@ const en: Messages = {
|
||||
peopleLogEvent: 'Event',
|
||||
peopleLogEmpty: 'Nothing logged today.',
|
||||
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitFull: 'Full body',
|
||||
peoplePortraitCustom: 'Custom prompt',
|
||||
@@ -444,6 +544,7 @@ const en: Messages = {
|
||||
peoplePortraitPromptNegative: 'Negative',
|
||||
peoplePortraitPromptLoading: 'Building prompt…',
|
||||
peoplePortraitPromptFailed: 'Could not load the prompt.',
|
||||
peoplePortraitPromptPreset: 'Preset: {label}',
|
||||
|
||||
modeOverview: 'Overview',
|
||||
modeManage: 'Management',
|
||||
@@ -498,6 +599,31 @@ const en: Messages = {
|
||||
staffErrorAssigned: 'That subject is already assigned.',
|
||||
staffErrorSubject: 'That subject is not in the catalog.',
|
||||
|
||||
manageTabStaff: 'Staff',
|
||||
manageTabRules: 'Rules',
|
||||
rulesCurrentTitle: 'Today',
|
||||
rulesPendingTitle: 'From tomorrow',
|
||||
rulesPendingHint: 'Changes take effect on the next work morning.',
|
||||
rulesStudentsRole: 'Students',
|
||||
rulesStaffRole: 'Staff',
|
||||
rulesStudentsForm: 'Student form',
|
||||
rulesStudentsColor: 'Student colours',
|
||||
rulesStaffForm: 'Staff form',
|
||||
rulesStaffColor: 'Staff colours',
|
||||
rulesRoleSummary: '{role}: {form}, {color}',
|
||||
rulesApply: 'Apply',
|
||||
rulesLoadFailed: 'Could not load dress rules.',
|
||||
rulesSaveFailed: 'Could not save the rules.',
|
||||
rulesPickHint: 'Rules apply to the whole school.',
|
||||
dressFormRegular: 'regular',
|
||||
dressFormShort: 'short',
|
||||
dressFormStrict: 'strict',
|
||||
dressColorFree: 'free colours',
|
||||
dressColorNoBright: 'no bright colours',
|
||||
dressColorWhiteTopBlackBottom: 'white top, black bottom',
|
||||
dressErrorUnknownForm: 'That form policy is not in the catalog.',
|
||||
dressErrorUnknownColor: 'That colour policy is not in the catalog.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
|
||||
@@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export interface SwarmUiKindPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
positive: string;
|
||||
}
|
||||
|
||||
export interface SwarmUiLoraEntry {
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface SwarmUiPresetDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
model: string;
|
||||
steps: number;
|
||||
cfgScale: number;
|
||||
clipSkip: number;
|
||||
sampler: string;
|
||||
scheduler: string;
|
||||
seed: number;
|
||||
positive: string;
|
||||
negative: string;
|
||||
positiveLoras: SwarmUiLoraEntry[];
|
||||
negativeLoras: SwarmUiLoraEntry[];
|
||||
avatar: SwarmUiKindPreset;
|
||||
custom: SwarmUiKindPreset;
|
||||
fullBody: SwarmUiKindPreset;
|
||||
}
|
||||
|
||||
export interface SwarmUiAgeRule {
|
||||
minAge: number;
|
||||
maxAge: number;
|
||||
presetId: string;
|
||||
}
|
||||
|
||||
export interface SwarmUiSettingsFile {
|
||||
activePresetId: string;
|
||||
presets: SwarmUiPresetDefinition[];
|
||||
ageRules: SwarmUiAgeRule[];
|
||||
}
|
||||
|
||||
export interface SwarmUiDiscovery {
|
||||
connected: boolean;
|
||||
models: readonly string[];
|
||||
loras: readonly string[];
|
||||
samplers: readonly string[];
|
||||
schedulers: readonly string[];
|
||||
}
|
||||
|
||||
export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> {
|
||||
return request<SwarmUiSettingsFile>('/api/settings/swarmui');
|
||||
}
|
||||
|
||||
export async function saveSwarmUiSettings(settings: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile> {
|
||||
return request<SwarmUiSettingsFile>('/api/settings/swarmui', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSwarmUiDiscovery(): Promise<SwarmUiDiscovery> {
|
||||
return request<SwarmUiDiscovery>('/api/settings/swarmui/discovery');
|
||||
}
|
||||
|
||||
export type PortraitKind = 'avatar' | 'full' | 'custom';
|
||||
|
||||
export interface PortraitResult {
|
||||
@@ -430,6 +496,8 @@ export interface PortraitPrompt {
|
||||
readonly positive: string;
|
||||
readonly negative: string;
|
||||
readonly promptExtra: string | null;
|
||||
readonly presetId: string;
|
||||
readonly presetLabel: string;
|
||||
}
|
||||
|
||||
export async function fetchPortraitPrompt(
|
||||
@@ -575,6 +643,33 @@ export async function unassignSubject(
|
||||
);
|
||||
}
|
||||
|
||||
export interface DressRulePair {
|
||||
readonly form: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export interface DressRules {
|
||||
readonly students: DressRulePair;
|
||||
readonly staff: DressRulePair;
|
||||
readonly pendingStudents: DressRulePair | null;
|
||||
readonly pendingStaff: DressRulePair | null;
|
||||
}
|
||||
|
||||
export async function fetchDressRules(schoolId: number): Promise<DressRules> {
|
||||
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`);
|
||||
}
|
||||
|
||||
export async function setDressRules(
|
||||
schoolId: number,
|
||||
body: { readonly students?: DressRulePair; readonly staff?: DressRulePair },
|
||||
): Promise<DressRules> {
|
||||
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export interface TimetableLesson {
|
||||
readonly classId: string;
|
||||
readonly classYear: number;
|
||||
|
||||
@@ -683,10 +683,45 @@ body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.people__portrait-prompt-text--muted {
|
||||
.form--settings {
|
||||
max-height: min(90vh, 960px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
margin: 20px 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-age-rule {
|
||||
align-items: end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-lora-row {
|
||||
align-items: end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-lora-row .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.people__portrait-prompt-meta {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.field--grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.people__swarm-status {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
@@ -1012,6 +1047,21 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rules__summary {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rules__fieldset {
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rules__fieldset .people__field + .people__field {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.staffing__badge {
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
fetchDressRules,
|
||||
setDressRules,
|
||||
type DressRules,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { DressRulesPanel } from './dressRulesPanel.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchDressRules: vi.fn(),
|
||||
setDressRules: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const initialLocale = getLocale();
|
||||
|
||||
function rules(): DressRules {
|
||||
return {
|
||||
students: { form: 'regular', color: 'noBright' },
|
||||
staff: { form: 'regular', color: 'noBright' },
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DressRulesPanel', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('en');
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(setDressRules).mockReset();
|
||||
vi.mocked(fetchDressRules).mockResolvedValue(rules());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
setLocale(initialLocale);
|
||||
});
|
||||
|
||||
it('does not call save when only a combobox changes', async () => {
|
||||
const panel = new DressRulesPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.show(3);
|
||||
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||
|
||||
const colorSelect = panel.element.querySelector('select.input');
|
||||
if (!(colorSelect instanceof HTMLSelectElement)) {
|
||||
throw new Error('student colour select is missing');
|
||||
}
|
||||
|
||||
colorSelect.value = 'free';
|
||||
colorSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(setDressRules).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an API error as text after apply', async () => {
|
||||
vi.mocked(setDressRules).mockRejectedValue(new ApiError(400, 'unknown-form', 'bad form'));
|
||||
const panel = new DressRulesPanel();
|
||||
document.body.append(panel.element);
|
||||
panel.show(3);
|
||||
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||
|
||||
const formSelect = panel.element.querySelector('fieldset select.input');
|
||||
if (!(formSelect instanceof HTMLSelectElement)) {
|
||||
throw new Error('student form select is missing');
|
||||
}
|
||||
|
||||
formSelect.value = 'strict';
|
||||
const apply = panel.element.querySelector('button.button');
|
||||
if (!(apply instanceof HTMLButtonElement)) {
|
||||
throw new Error('apply button is missing');
|
||||
}
|
||||
|
||||
apply.click();
|
||||
const banner = panel.element.querySelector('.staffing__error');
|
||||
if (!(banner instanceof HTMLElement)) {
|
||||
throw new Error('error banner is missing');
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(banner.hidden).toBe(false));
|
||||
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import {
|
||||
dressColorLabel,
|
||||
dressColorOptions,
|
||||
dressFormLabel,
|
||||
dressFormOptions,
|
||||
dressRulesError,
|
||||
} from './dressRulesUi.ts';
|
||||
import { fillSelect } from './staffingUi.ts';
|
||||
|
||||
/**
|
||||
* School-wide dress code. Edits queue for the next work morning; today's rules stay visible.
|
||||
*/
|
||||
export class DressRulesPanel {
|
||||
readonly element: HTMLElement;
|
||||
|
||||
private readonly error = el('p', { class: 'staffing__error' });
|
||||
private readonly currentTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly currentStudents = el('p', { class: 'rules__summary' });
|
||||
private readonly currentStaff = el('p', { class: 'rules__summary' });
|
||||
private readonly pendingTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly pendingHint = el('p', { class: 'panel__meta' });
|
||||
private readonly studentsFormLabel = el('span', { class: 'people__label' });
|
||||
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
|
||||
private readonly studentsColorLabel = el('span', { class: 'people__label' });
|
||||
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
|
||||
private readonly staffFormLabel = el('span', { class: 'people__label' });
|
||||
private readonly staffFormSelect = el('select', { class: 'input people__input' });
|
||||
private readonly staffColorLabel = el('span', { class: 'people__label' });
|
||||
private readonly staffColorSelect = el('select', { class: 'input people__input' });
|
||||
private readonly applyButton = el('button', { class: 'button', type: 'button' });
|
||||
|
||||
private readonly studentsLegend: HTMLElement;
|
||||
private readonly staffLegend: HTMLElement;
|
||||
|
||||
private schoolId: number | null = null;
|
||||
private rules: DressRules | null = null;
|
||||
private loadToken = 0;
|
||||
private busy = false;
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
this.applyButton.addEventListener('click', () => void this.apply());
|
||||
|
||||
this.element = el(
|
||||
'div',
|
||||
{ class: 'panel__body rules', hidden: true },
|
||||
this.error,
|
||||
el('div', { class: 'panel__section' }, this.currentTitle, this.currentStudents, this.currentStaff),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'panel__section' },
|
||||
this.pendingTitle,
|
||||
this.pendingHint,
|
||||
el(
|
||||
'fieldset',
|
||||
{ class: 'rules__fieldset' },
|
||||
el('legend', { class: 'people__section-title', text: '' }),
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field' },
|
||||
this.studentsFormLabel,
|
||||
this.studentsFormSelect,
|
||||
),
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field' },
|
||||
this.studentsColorLabel,
|
||||
this.studentsColorSelect,
|
||||
),
|
||||
),
|
||||
el(
|
||||
'fieldset',
|
||||
{ class: 'rules__fieldset' },
|
||||
el('legend', { class: 'people__section-title', text: '' }),
|
||||
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
|
||||
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
|
||||
),
|
||||
this.applyButton,
|
||||
),
|
||||
);
|
||||
|
||||
const legends = this.element.querySelectorAll('fieldset.rules__fieldset > legend');
|
||||
const studentsLegend = legends.item(0);
|
||||
const staffLegend = legends.item(1);
|
||||
if (!(studentsLegend instanceof HTMLElement) || !(staffLegend instanceof HTMLElement)) {
|
||||
throw new Error('rules fieldset legends are missing');
|
||||
}
|
||||
|
||||
this.studentsLegend = studentsLegend;
|
||||
this.staffLegend = staffLegend;
|
||||
this.localize();
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.currentTitle.textContent = t('rulesCurrentTitle');
|
||||
this.pendingTitle.textContent = t('rulesPendingTitle');
|
||||
this.pendingHint.textContent = t('rulesPendingHint');
|
||||
this.studentsFormLabel.textContent = t('rulesStudentsForm');
|
||||
this.studentsColorLabel.textContent = t('rulesStudentsColor');
|
||||
this.staffFormLabel.textContent = t('rulesStaffForm');
|
||||
this.staffColorLabel.textContent = t('rulesStaffColor');
|
||||
this.applyButton.textContent = t('rulesApply');
|
||||
this.studentsLegend.textContent = t('rulesStudentsRole');
|
||||
this.staffLegend.textContent = t('rulesStaffRole');
|
||||
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
|
||||
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
|
||||
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
|
||||
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
|
||||
this.paint();
|
||||
}
|
||||
|
||||
show(schoolId: number): void {
|
||||
if (this.schoolId !== schoolId) {
|
||||
this.schoolId = schoolId;
|
||||
this.rules = null;
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.element.hidden = true;
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
this.element.hidden = false;
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.loadToken;
|
||||
try {
|
||||
const rules = await fetchDressRules(schoolId);
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rules = rules;
|
||||
this.paint();
|
||||
} catch {
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showError(t('rulesLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
private paint(): void {
|
||||
const rules = this.rules;
|
||||
this.applyButton.disabled = this.busy || rules === null;
|
||||
|
||||
if (rules === null) {
|
||||
this.currentStudents.textContent = '—';
|
||||
this.currentStaff.textContent = '—';
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStudents.textContent = t('rulesRoleSummary', {
|
||||
role: t('rulesStudentsRole'),
|
||||
form: dressFormLabel(rules.students.form),
|
||||
color: dressColorLabel(rules.students.color),
|
||||
});
|
||||
this.currentStaff.textContent = t('rulesRoleSummary', {
|
||||
role: t('rulesStaffRole'),
|
||||
form: dressFormLabel(rules.staff.form),
|
||||
color: dressColorLabel(rules.staff.color),
|
||||
});
|
||||
|
||||
const pendingStudents = rules.pendingStudents ?? rules.students;
|
||||
const pendingStaff = rules.pendingStaff ?? rules.staff;
|
||||
fillSelect(this.studentsFormSelect, dressFormOptions(), pendingStudents.form);
|
||||
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
|
||||
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
|
||||
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const rules = this.rules;
|
||||
if (schoolId === null || rules === null || this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const students = this.pair(this.studentsFormSelect, this.studentsColorSelect);
|
||||
const staff = this.pair(this.staffFormSelect, this.staffColorSelect);
|
||||
const body: { students?: DressRulePair; staff?: DressRulePair } = {};
|
||||
if (!samePair(students, rules.pendingStudents ?? rules.students)) {
|
||||
body.students = students;
|
||||
}
|
||||
|
||||
if (!samePair(staff, rules.pendingStaff ?? rules.staff)) {
|
||||
body.staff = staff;
|
||||
}
|
||||
|
||||
if (body.students === undefined && body.staff === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.clearError();
|
||||
this.paint();
|
||||
try {
|
||||
this.rules = await setDressRules(schoolId, body);
|
||||
this.paint();
|
||||
} catch (error) {
|
||||
this.showError(dressRulesError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
}
|
||||
}
|
||||
|
||||
private pair(formSelect: HTMLSelectElement, colorSelect: HTMLSelectElement): DressRulePair {
|
||||
return { form: formSelect.value, color: colorSelect.value };
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.error.hidden = false;
|
||||
this.error.textContent = message;
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.error.hidden = true;
|
||||
this.error.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function samePair(left: DressRulePair, right: DressRulePair): boolean {
|
||||
return left.form === right.form && left.color === right.color;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiError } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
|
||||
export const DRESS_FORMS = ['regular', 'short', 'strict'] as const;
|
||||
export const DRESS_COLORS = ['free', 'noBright', 'whiteTopBlackBottom'] as const;
|
||||
|
||||
export type DressForm = (typeof DRESS_FORMS)[number];
|
||||
export type DressColor = (typeof DRESS_COLORS)[number];
|
||||
|
||||
export function dressFormOptions(): readonly { value: DressForm; label: string }[] {
|
||||
return DRESS_FORMS.map((value) => ({ value, label: dressFormLabel(value) }));
|
||||
}
|
||||
|
||||
export function dressColorOptions(): readonly { value: DressColor; label: string }[] {
|
||||
return DRESS_COLORS.map((value) => ({ value, label: dressColorLabel(value) }));
|
||||
}
|
||||
|
||||
export function dressFormLabel(form: string): string {
|
||||
switch (form) {
|
||||
case 'regular':
|
||||
return t('dressFormRegular');
|
||||
case 'short':
|
||||
return t('dressFormShort');
|
||||
case 'strict':
|
||||
return t('dressFormStrict');
|
||||
default:
|
||||
return form;
|
||||
}
|
||||
}
|
||||
|
||||
export function dressColorLabel(color: string): string {
|
||||
switch (color) {
|
||||
case 'free':
|
||||
return t('dressColorFree');
|
||||
case 'noBright':
|
||||
return t('dressColorNoBright');
|
||||
case 'whiteTopBlackBottom':
|
||||
return t('dressColorWhiteTopBlackBottom');
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
export function dressRulesError(error: unknown): string {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return t('rulesSaveFailed');
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case 'unknown-form':
|
||||
return t('dressErrorUnknownForm');
|
||||
case 'unknown-color':
|
||||
return t('dressErrorUnknownColor');
|
||||
default:
|
||||
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { confirmDialog } from './confirmDialog.ts';
|
||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
||||
import { SchoolCard } from './schoolCard.ts';
|
||||
|
||||
interface MainMenuOptions {
|
||||
@@ -35,6 +36,10 @@ export class MainMenu {
|
||||
class: 'button button--primary',
|
||||
type: 'button',
|
||||
});
|
||||
private readonly settingsButton = el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
private readonly limitHint = el('p', { class: 'hint' });
|
||||
private readonly status = el('p', { class: 'hint hint--error' });
|
||||
@@ -47,6 +52,7 @@ export class MainMenu {
|
||||
|
||||
constructor(private readonly options: MainMenuOptions) {
|
||||
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
||||
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
|
||||
this.status.hidden = true;
|
||||
|
||||
this.root.append(
|
||||
@@ -54,7 +60,7 @@ export class MainMenu {
|
||||
'header',
|
||||
{ class: 'screen__header' },
|
||||
this.title,
|
||||
el('div', { class: 'screen__actions' }, this.createButton),
|
||||
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
|
||||
),
|
||||
this.limitHint,
|
||||
this.status,
|
||||
@@ -73,6 +79,7 @@ export class MainMenu {
|
||||
localize(): void {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
this.settingsButton.textContent = t('settings');
|
||||
this.emptyHint.textContent = t('emptySchools');
|
||||
|
||||
for (const card of this.cards.values()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiError,
|
||||
fetchPerson,
|
||||
fetchGameStatus,
|
||||
fetchDressRules,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
hireStaff,
|
||||
@@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchStaffing: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
fetchDressRules: vi.fn(),
|
||||
fetchPerson: vi.fn(),
|
||||
fetchGameStatus: vi.fn().mockResolvedValue({
|
||||
tick: 0,
|
||||
@@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => {
|
||||
setLocale('en');
|
||||
vi.mocked(fetchStaffing).mockReset();
|
||||
vi.mocked(fetchTimetable).mockReset();
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||
...staffing(),
|
||||
applicants: [],
|
||||
@@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => {
|
||||
],
|
||||
});
|
||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||
students: { form: 'regular', color: 'noBright' },
|
||||
staff: { form: 'regular', color: 'noBright' },
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -248,3 +257,56 @@ describe('ManagementPanel uncovered', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManagementPanel rules tab', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('en');
|
||||
vi.mocked(fetchStaffing).mockReset();
|
||||
vi.mocked(fetchTimetable).mockReset();
|
||||
vi.mocked(fetchDressRules).mockReset();
|
||||
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||
students: { form: 'regular', color: 'noBright' },
|
||||
staff: { form: 'regular', color: 'noBright' },
|
||||
pendingStudents: null,
|
||||
pendingStaff: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
setLocale(initialLocale);
|
||||
});
|
||||
|
||||
it('keeps staffing and the timetable grid after visiting rules', async () => {
|
||||
const panel = new ManagementPanel();
|
||||
document.body.append(panel.listElement, panel.cardElement);
|
||||
panel.show(2);
|
||||
await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled());
|
||||
|
||||
const rulesTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
|
||||
(button) => button.textContent === t('manageTabRules'),
|
||||
);
|
||||
if (!(rulesTab instanceof HTMLButtonElement)) {
|
||||
throw new Error('rules tab is missing');
|
||||
}
|
||||
|
||||
rulesTab.click();
|
||||
expect(panel.listElement.querySelector('.staffing')?.hasAttribute('hidden')).toBe(true);
|
||||
expect(panel.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false);
|
||||
|
||||
const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
|
||||
(button) => button.textContent === t('manageTabStaff'),
|
||||
);
|
||||
if (!(staffTab instanceof HTMLButtonElement)) {
|
||||
throw new Error('staff tab is missing');
|
||||
}
|
||||
|
||||
staffTab.click();
|
||||
await vi.waitFor(() => expect(panel.listElement.querySelector('.staffing')).not.toBeNull());
|
||||
expect(panel.listElement.textContent).toContain(t('staffHired'));
|
||||
expect(panel.listElement.textContent).toContain(t('timetableTitle'));
|
||||
expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { ApplicantsDialog } from './applicantsDialog.ts';
|
||||
import { DressRulesPanel } from './dressRulesPanel.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { formatPersonPlace } from './personCard.ts';
|
||||
import { PersonCardHost } from './personCardHost.ts';
|
||||
@@ -28,6 +29,10 @@ export class ManagementPanel {
|
||||
readonly listElement: HTMLElement;
|
||||
readonly cardElement: HTMLElement;
|
||||
|
||||
private readonly staffTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||
private readonly rulesTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||
private readonly staffPane = el('div', { class: 'panel__body staffing' });
|
||||
private readonly rulesPanel = new DressRulesPanel();
|
||||
private readonly error = el('p', { class: 'staffing__error' });
|
||||
private readonly money = el('dl', { class: 'staffing__money' });
|
||||
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
|
||||
@@ -65,13 +70,14 @@ export class ManagementPanel {
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private poolDialog: ApplicantsDialog | null = null;
|
||||
private readonly cardHost = new PersonCardHost();
|
||||
private manageTab: 'staff' | 'rules' = 'staff';
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
|
||||
this.listElement = el(
|
||||
'div',
|
||||
{ class: 'panel__body staffing' },
|
||||
this.staffTab.addEventListener('click', () => this.showManageTab('staff'));
|
||||
this.rulesTab.addEventListener('click', () => this.showManageTab('rules'));
|
||||
this.staffPane.append(
|
||||
this.error,
|
||||
this.money,
|
||||
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||
@@ -96,6 +102,13 @@ export class ManagementPanel {
|
||||
this.timetableGrid.element,
|
||||
),
|
||||
);
|
||||
this.listElement = el(
|
||||
'div',
|
||||
{},
|
||||
el('div', { class: 'panel__tabs' }, this.staffTab, this.rulesTab),
|
||||
this.staffPane,
|
||||
this.rulesPanel.element,
|
||||
);
|
||||
this.cardElement = this.card;
|
||||
this.classSelect.addEventListener('change', () => {
|
||||
this.classId = this.classSelect.value || null;
|
||||
@@ -105,6 +118,8 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.staffTab.textContent = t('manageTabStaff');
|
||||
this.rulesTab.textContent = t('manageTabRules');
|
||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||
this.applicantsTitle.textContent = t('staffApplicants');
|
||||
this.applicantsButton.textContent = t('staffApplicantsOpen');
|
||||
@@ -116,6 +131,8 @@ export class ManagementPanel {
|
||||
this.timetableGrid.localize();
|
||||
this.personGrid.localize();
|
||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||
this.rulesPanel.localize();
|
||||
this.paintManageTabs();
|
||||
this.paint();
|
||||
this.poolDialog?.localize();
|
||||
if (this.selectedId !== null) {
|
||||
@@ -133,12 +150,43 @@ export class ManagementPanel {
|
||||
this.staffing = null;
|
||||
this.timetable = null;
|
||||
this.classId = null;
|
||||
this.manageTab = 'staff';
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
this.paintManageTabs();
|
||||
|
||||
this.timetableGrid.attach(schoolId);
|
||||
this.cardHost.attach(schoolId);
|
||||
void this.reload();
|
||||
if (this.manageTab === 'rules') {
|
||||
this.rulesPanel.show(schoolId);
|
||||
}
|
||||
}
|
||||
|
||||
private showManageTab(tab: 'staff' | 'rules'): void {
|
||||
this.manageTab = tab;
|
||||
this.paintManageTabs();
|
||||
const schoolId = this.schoolId;
|
||||
if (tab === 'rules' && schoolId !== null) {
|
||||
this.rulesPanel.show(schoolId);
|
||||
this.paintCard(null);
|
||||
} else if (this.selectedId !== null) {
|
||||
void this.openCard(this.selectedId);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
}
|
||||
|
||||
private paintManageTabs(): void {
|
||||
this.staffTab.classList.toggle('panel__tab--active', this.manageTab === 'staff');
|
||||
this.rulesTab.classList.toggle('panel__tab--active', this.manageTab === 'rules');
|
||||
this.staffPane.hidden = this.manageTab !== 'staff';
|
||||
if (this.manageTab === 'rules') {
|
||||
this.rulesPanel.reveal();
|
||||
} else {
|
||||
this.rulesPanel.hide();
|
||||
}
|
||||
}
|
||||
|
||||
setLocate(locate: (id: string) => string): void {
|
||||
@@ -402,6 +450,12 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
if (this.manageTab === 'rules') {
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('rulesPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (card === null) {
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||
|
||||
@@ -489,6 +489,7 @@ function portraitPromptView(
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el('div', { class: 'people__portrait-prompt-view' },
|
||||
el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
|
||||
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
ApiError,
|
||||
fetchGameStatus,
|
||||
fetchSwarmUiDiscovery,
|
||||
fetchSwarmUiSettings,
|
||||
saveSwarmUiSettings,
|
||||
type SwarmUiDiscovery,
|
||||
type SwarmUiLoraEntry,
|
||||
type SwarmUiPresetDefinition,
|
||||
type SwarmUiSettingsFile,
|
||||
} from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
|
||||
export function swarmUiSettingsDialog(): Promise<boolean> {
|
||||
const modal = new Modal<boolean>(false);
|
||||
const error = el('p', { class: 'dialog__error' });
|
||||
error.hidden = true;
|
||||
|
||||
const statusLine = el('p', { class: 'hint' });
|
||||
const editPresetSelect = el('select', { class: 'input' });
|
||||
const activePresetSelect = el('select', { class: 'input' });
|
||||
const formHost = el('div', { class: 'settings-form' });
|
||||
const ageRulesHost = el('div', { class: 'settings-age-rules' });
|
||||
|
||||
let config: SwarmUiSettingsFile | null = null;
|
||||
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
|
||||
let editingPresetId = '';
|
||||
|
||||
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) });
|
||||
const saveButton = el('button', { class: 'button button--primary', type: 'submit' });
|
||||
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
const refreshDiscoveryButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'form form--settings' },
|
||||
el('h2', { class: 'dialog__title' }),
|
||||
statusLine,
|
||||
el('div', { class: 'field__row' }, refreshDiscoveryButton),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label' }), activePresetSelect),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'field field__row' },
|
||||
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label' }), editPresetSelect),
|
||||
addPresetButton,
|
||||
),
|
||||
formHost,
|
||||
el('h3', { class: 'settings-section-title' }),
|
||||
ageRulesHost,
|
||||
el('div', { class: 'field__row' }, addAgeRuleButton),
|
||||
error,
|
||||
el('div', { class: 'dialog__actions' }, cancelButton, saveButton),
|
||||
);
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
});
|
||||
|
||||
addPresetButton.addEventListener('click', () => {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `preset-${config.presets.length + 1}`;
|
||||
const source = currentPreset() ?? config.presets[0];
|
||||
config.presets.push({
|
||||
...structuredClone(source),
|
||||
id,
|
||||
label: id,
|
||||
});
|
||||
editingPresetId = id;
|
||||
paintPresetSelectors();
|
||||
paintPresetForm();
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
addAgeRuleButton.addEventListener('click', () => {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.ageRules.push({
|
||||
minAge: 6,
|
||||
maxAge: 11,
|
||||
presetId: config.activePresetId,
|
||||
});
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
refreshDiscoveryButton.addEventListener('click', () => void reloadDiscovery());
|
||||
editPresetSelect.addEventListener('change', () => {
|
||||
editingPresetId = editPresetSelect.value;
|
||||
paintPresetForm();
|
||||
});
|
||||
activePresetSelect.addEventListener('change', () => {
|
||||
if (config !== null) {
|
||||
config.activePresetId = activePresetSelect.value;
|
||||
}
|
||||
|
||||
paintAgeRules();
|
||||
});
|
||||
|
||||
const dialog = modal.element;
|
||||
dialog.classList.add('dialog--screen');
|
||||
dialog.append(form);
|
||||
|
||||
void init();
|
||||
|
||||
return modal.open(saveButton);
|
||||
|
||||
async function init(): Promise<void> {
|
||||
localize();
|
||||
try {
|
||||
const [settings, gameStatus, lists] = await Promise.all([
|
||||
fetchSwarmUiSettings(),
|
||||
fetchGameStatus(),
|
||||
fetchSwarmUiDiscovery(),
|
||||
]);
|
||||
config = settings;
|
||||
discovery = lists;
|
||||
editingPresetId = settings.presets[0]?.id ?? '';
|
||||
paintStatus(gameStatus.swarmUiConfigured, gameStatus.swarmUiConnected, lists.connected);
|
||||
paintPresetSelectors();
|
||||
paintPresetForm();
|
||||
paintAgeRules();
|
||||
} catch {
|
||||
showError(t('settingsLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDiscovery(): Promise<void> {
|
||||
refreshDiscoveryButton.disabled = true;
|
||||
try {
|
||||
discovery = await fetchSwarmUiDiscovery();
|
||||
paintStatus(true, discovery.connected, discovery.connected);
|
||||
paintPresetForm();
|
||||
} catch {
|
||||
showError(t('settingsDiscoveryFailed'));
|
||||
} finally {
|
||||
refreshDiscoveryButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncActivePresetFromForm();
|
||||
saveButton.disabled = true;
|
||||
error.hidden = true;
|
||||
try {
|
||||
await saveSwarmUiSettings(config);
|
||||
modal.close(true);
|
||||
} catch (err) {
|
||||
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
|
||||
} finally {
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
error.textContent = message;
|
||||
error.hidden = false;
|
||||
}
|
||||
|
||||
function paintStatus(configured: boolean, connected: boolean | null, discoveryConnected: boolean): void {
|
||||
if (!configured) {
|
||||
statusLine.textContent = t('settingsSwarmNotConfigured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connected === true && discoveryConnected) {
|
||||
statusLine.textContent = t('settingsSwarmReady');
|
||||
return;
|
||||
}
|
||||
|
||||
statusLine.textContent = t('settingsSwarmOffline');
|
||||
}
|
||||
|
||||
function paintPresetSelectors(): void {
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
editPresetSelect.replaceChildren(
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
editPresetSelect.value = editingPresetId;
|
||||
|
||||
activePresetSelect.replaceChildren(
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
activePresetSelect.value = config.activePresetId;
|
||||
}
|
||||
|
||||
function currentPreset(): SwarmUiPresetDefinition | undefined {
|
||||
return config?.presets.find((preset) => preset.id === editingPresetId);
|
||||
}
|
||||
|
||||
function paintPresetForm(): void {
|
||||
formHost.replaceChildren();
|
||||
const preset = currentPreset();
|
||||
if (preset === null || preset === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
formHost.append(
|
||||
textField(t('settingsPresetLabel'), preset.label, (value) => {
|
||||
preset.label = value;
|
||||
paintPresetSelectors();
|
||||
}),
|
||||
choiceField(t('settingsModel'), preset.model, discovery.models, (value) => {
|
||||
preset.model = value;
|
||||
}),
|
||||
numberField(t('settingsSteps'), preset.steps, (value) => {
|
||||
preset.steps = value;
|
||||
}),
|
||||
numberField(t('settingsCfg'), preset.cfgScale, (value) => {
|
||||
preset.cfgScale = value;
|
||||
}, 0.1),
|
||||
numberField(t('settingsClipSkip'), preset.clipSkip, (value) => {
|
||||
preset.clipSkip = value;
|
||||
}),
|
||||
choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => {
|
||||
preset.sampler = value;
|
||||
}),
|
||||
choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => {
|
||||
preset.scheduler = value;
|
||||
}),
|
||||
numberField(t('settingsSeed'), preset.seed, (value) => {
|
||||
preset.seed = value;
|
||||
}),
|
||||
textareaField(t('settingsPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
}),
|
||||
textareaField(t('settingsNegative'), preset.negative, (value) => {
|
||||
preset.negative = value;
|
||||
}),
|
||||
loraSection(t('settingsPositiveLoras'), preset.positiveLoras),
|
||||
loraSection(t('settingsNegativeLoras'), preset.negativeLoras),
|
||||
kindSection(t('settingsAvatarPreset'), preset.avatar),
|
||||
kindSection(t('settingsCustomPreset'), preset.custom),
|
||||
kindSection(t('settingsFullBodyPreset'), preset.fullBody),
|
||||
);
|
||||
}
|
||||
|
||||
function syncActivePresetFromForm(): void {
|
||||
// Values are bound live through closures; nothing extra to scrape from DOM.
|
||||
}
|
||||
|
||||
function paintAgeRules(): void {
|
||||
ageRulesHost.replaceChildren();
|
||||
if (config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rule of config.ageRules) {
|
||||
const minInput = el('input', { class: 'input', type: 'number', value: String(rule.minAge) });
|
||||
minInput.min = '0';
|
||||
minInput.max = '120';
|
||||
minInput.addEventListener('input', () => {
|
||||
rule.minAge = Number(minInput.value);
|
||||
});
|
||||
|
||||
const maxInput = el('input', { class: 'input', type: 'number', value: String(rule.maxAge) });
|
||||
maxInput.min = '0';
|
||||
maxInput.max = '120';
|
||||
maxInput.addEventListener('input', () => {
|
||||
rule.maxAge = Number(maxInput.value);
|
||||
});
|
||||
|
||||
const presetSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
|
||||
);
|
||||
presetSelect.value = rule.presetId;
|
||||
presetSelect.addEventListener('change', () => {
|
||||
rule.presetId = presetSelect.value;
|
||||
});
|
||||
|
||||
const removeButton = el('button', {
|
||||
class: 'button button--small button--danger',
|
||||
type: 'button',
|
||||
text: t('settingsRemoveRule'),
|
||||
onClick: () => {
|
||||
config!.ageRules = config!.ageRules.filter((entry) => entry !== rule);
|
||||
paintAgeRules();
|
||||
},
|
||||
});
|
||||
|
||||
ageRulesHost.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'settings-age-rule field__row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMin') }), minInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMax') }), maxInput),
|
||||
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label', text: t('settingsAgePreset') }), presetSelect),
|
||||
removeButton,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement {
|
||||
const host = el('div', { class: 'settings-loras' });
|
||||
const heading = el('h4', { class: 'settings-subtitle', text: title });
|
||||
const rows = el('div', { class: 'settings-lora-rows' });
|
||||
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren();
|
||||
for (const lora of loras) {
|
||||
const nameField = choiceField('', lora.name, discovery.loras, (value) => {
|
||||
lora.name = value;
|
||||
});
|
||||
const weightInput = el('input', { class: 'input', type: 'number', value: String(lora.weight) });
|
||||
weightInput.step = '0.05';
|
||||
weightInput.min = '-4';
|
||||
weightInput.max = '4';
|
||||
weightInput.addEventListener('input', () => {
|
||||
lora.weight = Number(weightInput.value);
|
||||
});
|
||||
const remove = el('button', {
|
||||
class: 'button button--small button--danger',
|
||||
type: 'button',
|
||||
text: '×',
|
||||
onClick: () => {
|
||||
const index = loras.indexOf(lora);
|
||||
if (index >= 0) {
|
||||
loras.splice(index, 1);
|
||||
}
|
||||
|
||||
paintRows();
|
||||
},
|
||||
});
|
||||
rows.append(el('div', { class: 'field__row settings-lora-row' }, nameField, weightInput, remove));
|
||||
}
|
||||
};
|
||||
|
||||
paintRows();
|
||||
const add = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('settingsAddLora'),
|
||||
onClick: () => {
|
||||
loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
|
||||
paintRows();
|
||||
},
|
||||
});
|
||||
|
||||
host.append(heading, rows, add);
|
||||
return host;
|
||||
}
|
||||
|
||||
function kindSection(title: string, preset: SwarmUiPresetDefinition['avatar']): HTMLElement {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'settings-kind' },
|
||||
el('h4', { class: 'settings-subtitle', text: title }),
|
||||
numberField(t('settingsWidth'), preset.width, (value) => {
|
||||
preset.width = value;
|
||||
}),
|
||||
numberField(t('settingsHeight'), preset.height, (value) => {
|
||||
preset.height = value;
|
||||
}),
|
||||
textareaField(t('settingsKindPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function localize(): void {
|
||||
form.querySelector('.dialog__title')!.textContent = t('settingsTitle');
|
||||
form.querySelectorAll('.field__label')[0]!.textContent = t('settingsActivePreset');
|
||||
form.querySelectorAll('.field__label')[1]!.textContent = t('settingsEditPreset');
|
||||
form.querySelector('.settings-section-title')!.textContent = t('settingsAgeRules');
|
||||
cancelButton.textContent = t('cancel');
|
||||
saveButton.textContent = t('save');
|
||||
addPresetButton.textContent = t('settingsAddPreset');
|
||||
addAgeRuleButton.textContent = t('settingsAddAgeRule');
|
||||
refreshDiscoveryButton.textContent = t('settingsRefreshLists');
|
||||
}
|
||||
}
|
||||
|
||||
function textField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
|
||||
const input = el('input', { class: 'input', type: 'text', value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function numberField(
|
||||
label: string,
|
||||
value: number,
|
||||
onChange: (value: number) => void,
|
||||
step = 1,
|
||||
): HTMLElement {
|
||||
const input = el('input', { class: 'input', type: 'number', value: String(value) });
|
||||
input.step = String(step);
|
||||
input.addEventListener('input', () => onChange(Number(input.value)));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function textareaField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
|
||||
const input = el('textarea', { class: 'input', rows: 3, value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function choiceField(
|
||||
label: string,
|
||||
value: string,
|
||||
options: readonly string[],
|
||||
onChange: (value: string) => void,
|
||||
): HTMLElement {
|
||||
const values = options.includes(value) ? options : value.length > 0 ? [value, ...options] : options;
|
||||
if (values.length === 0) {
|
||||
const input = el('input', { class: 'input', type: 'text', value });
|
||||
input.addEventListener('input', () => onChange(input.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
const select = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...values.map((entry) => el('option', { value: entry, text: entry })),
|
||||
);
|
||||
select.value = value;
|
||||
select.addEventListener('change', () => onChange(select.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
|
||||
}
|
||||
@@ -112,6 +112,21 @@ public static class Appropriateness
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal))
|
||||
{
|
||||
if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal)
|
||||
&& !color.Equals("White", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal)
|
||||
&& !color.Equals("Black", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,9 @@ internal static class SchoolEndpoints
|
||||
PortraitKindParser.ToApiValue(result.Kind),
|
||||
result.Positive,
|
||||
result.Negative,
|
||||
result.PromptExtra)),
|
||||
result.PromptExtra,
|
||||
result.PresetId,
|
||||
result.PresetLabel)),
|
||||
PortraitPromptBuildOutcome.InvalidPrompt =>
|
||||
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
|
||||
PortraitPromptBuildOutcome.UnknownPerson =>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using HSchool.Server.Game;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal static class SettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var settings = endpoints.MapGroup("/api/settings");
|
||||
|
||||
settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current))
|
||||
.WithName("GetSwarmUiSettings");
|
||||
|
||||
settings.MapPut("/swarmui", (SwarmUiConfigFile body, SwarmUiSettingsStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
body.NormalizeAfterLoad();
|
||||
return Results.Ok(store.Save(body));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-body", ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("PutSwarmUiSettings");
|
||||
|
||||
settings.MapGet("/swarmui/discovery", async (SwarmUiClient swarm, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!swarm.IsConfigured)
|
||||
{
|
||||
return Results.Ok(SwarmUiDiscovery.Offline);
|
||||
}
|
||||
|
||||
var discovery = await swarm.FetchDiscoveryAsync(cancellationToken);
|
||||
return Results.Ok(discovery);
|
||||
})
|
||||
.WithName("GetSwarmUiDiscovery");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult Problem(int status, string code, string detail) =>
|
||||
Results.Problem(detail, statusCode: status, extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
@@ -7,16 +7,16 @@ internal static class PortraitPromptBuilder
|
||||
{
|
||||
public static (string Positive, string Negative) Build(
|
||||
PersonCardResponse card,
|
||||
SwarmUiSettings settings,
|
||||
SwarmUiResolvedProfile profile,
|
||||
PortraitKind kind,
|
||||
string? promptExtra = null)
|
||||
{
|
||||
var preset = settings.PresetFor(kind);
|
||||
var kindPreset = profile.KindPreset;
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Positive))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Positive))
|
||||
{
|
||||
parts.Add(settings.Positive.Trim());
|
||||
parts.Add(profile.Positive.Trim());
|
||||
}
|
||||
|
||||
if (kind == PortraitKind.Custom)
|
||||
@@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder
|
||||
parts.Add(promptExtra.Trim());
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(preset.Positive))
|
||||
else if (!string.IsNullOrWhiteSpace(kindPreset.Positive))
|
||||
{
|
||||
parts.Add(preset.Positive.Trim());
|
||||
parts.Add(kindPreset.Positive.Trim());
|
||||
}
|
||||
|
||||
parts.Add(card.Female ? "A young woman" : "A young man");
|
||||
parts.Add(DescribeSubject(card));
|
||||
parts.Add($"age {card.Age}");
|
||||
|
||||
foreach (var row in card.Body)
|
||||
@@ -53,7 +53,25 @@ internal static class PortraitPromptBuilder
|
||||
}
|
||||
|
||||
var positive = string.Join(", ", parts.Where(part => part.Length > 0));
|
||||
var negative = settings.Negative?.Trim() ?? string.Empty;
|
||||
var negative = SwarmUiLoraFormatter.AppendLoraTags(
|
||||
profile.Negative.Trim(),
|
||||
profile.NegativeLoras);
|
||||
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
private static string DescribeSubject(PersonCardResponse card)
|
||||
{
|
||||
if (card.Age <= 11)
|
||||
{
|
||||
return card.Female ? "A young girl" : "A young boy";
|
||||
}
|
||||
|
||||
if (card.Age <= 17)
|
||||
{
|
||||
return card.Female ? "A teenage girl" : "A teenage boy";
|
||||
}
|
||||
|
||||
return card.Female ? "A young woman" : "A young man";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace HSchool.Server.Game;
|
||||
internal sealed class PortraitService(
|
||||
SchoolStore store,
|
||||
SwarmUiClient swarm,
|
||||
SwarmUiSettings settings,
|
||||
SwarmUiSettingsStore settingsStore,
|
||||
GameCommandQueue commands,
|
||||
ILogger<PortraitService> logger)
|
||||
{
|
||||
@@ -74,12 +74,15 @@ internal sealed class PortraitService(
|
||||
return PortraitPromptBuildResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved);
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
|
||||
return PortraitPromptBuildResult.Succeeded(
|
||||
kind,
|
||||
positive,
|
||||
negative,
|
||||
kind == PortraitKind.Custom ? resolved : null);
|
||||
kind == PortraitKind.Custom ? resolved : null,
|
||||
profile.PresetId,
|
||||
profile.PresetLabel);
|
||||
}
|
||||
|
||||
public async Task<PortraitGenerationResult> GenerateAsync(
|
||||
@@ -119,11 +122,12 @@ internal sealed class PortraitService(
|
||||
return PortraitGenerationResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra);
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken);
|
||||
store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
|
||||
var flags = Flags(schoolId, personId);
|
||||
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
|
||||
@@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult(
|
||||
PortraitKind Kind,
|
||||
string Positive,
|
||||
string Negative,
|
||||
string? PromptExtra)
|
||||
string? PromptExtra,
|
||||
string PresetId,
|
||||
string PresetLabel)
|
||||
{
|
||||
public static PortraitPromptBuildResult UnknownSchool { get; } =
|
||||
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult UnknownPerson { get; } =
|
||||
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult InvalidPrompt { get; } =
|
||||
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null);
|
||||
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null, "", "");
|
||||
|
||||
public static PortraitPromptBuildResult Succeeded(
|
||||
PortraitKind kind,
|
||||
string positive,
|
||||
string negative,
|
||||
string? promptExtra) =>
|
||||
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra);
|
||||
string? promptExtra,
|
||||
string presetId,
|
||||
string presetLabel) =>
|
||||
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel);
|
||||
}
|
||||
|
||||
internal sealed record PortraitPromptResponse(
|
||||
string Kind,
|
||||
string Positive,
|
||||
string Negative,
|
||||
string? PromptExtra);
|
||||
string? PromptExtra,
|
||||
string PresetId,
|
||||
string PresetLabel);
|
||||
|
||||
internal enum PortraitGenerationOutcome
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using HSchool.Content;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
using HSchool.Simulation;
|
||||
|
||||
@@ -55,11 +55,39 @@ internal sealed class SwarmUiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SwarmUiDiscovery> FetchDiscoveryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
return SwarmUiDiscovery.Offline;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await RunWithSessionAsync(async () =>
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(new { session_id = _sessionId }),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
using var response = await _http.PostAsync("/API/ListT2IParams", content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return SwarmUiDiscoveryParser.Parse(document.RootElement);
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "SwarmUI discovery failed.");
|
||||
return SwarmUiDiscovery.Offline;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateAsync(
|
||||
string prompt,
|
||||
string negativePrompt,
|
||||
SwarmUiSettings settings,
|
||||
PortraitKind kind,
|
||||
SwarmUiResolvedProfile profile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
@@ -69,7 +97,7 @@ internal sealed class SwarmUiClient
|
||||
|
||||
return await RunWithSessionAsync(async () =>
|
||||
{
|
||||
var preset = settings.PresetFor(kind);
|
||||
var kind = profile.KindPreset;
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = _sessionId,
|
||||
@@ -77,28 +105,33 @@ internal sealed class SwarmUiClient
|
||||
["donotsave"] = true,
|
||||
["prompt"] = prompt,
|
||||
["negativeprompt"] = negativePrompt,
|
||||
["model"] = settings.Model,
|
||||
["steps"] = settings.Steps,
|
||||
["cfgscale"] = settings.CfgScale,
|
||||
["width"] = preset.Width,
|
||||
["height"] = preset.Height,
|
||||
["seed"] = settings.Seed,
|
||||
["model"] = profile.Model,
|
||||
["steps"] = profile.Steps,
|
||||
["cfgscale"] = profile.CfgScale,
|
||||
["width"] = kind.Width,
|
||||
["height"] = kind.Height,
|
||||
["seed"] = profile.Seed,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Sampler))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Sampler))
|
||||
{
|
||||
body["sampler"] = settings.Sampler;
|
||||
body["sampler"] = profile.Sampler;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Scheduler))
|
||||
if (!string.IsNullOrWhiteSpace(profile.Scheduler))
|
||||
{
|
||||
body["scheduler"] = settings.Scheduler;
|
||||
body["scheduler"] = profile.Scheduler;
|
||||
}
|
||||
|
||||
if (settings.ClipSkip > 0)
|
||||
if (profile.ClipSkip > 0)
|
||||
{
|
||||
// SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end.
|
||||
body["clipstopatlayer"] = -settings.ClipSkip;
|
||||
body["clipstopatlayer"] = -profile.ClipSkip;
|
||||
}
|
||||
|
||||
var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras);
|
||||
if (loras is not null)
|
||||
{
|
||||
body["loras"] = loras;
|
||||
}
|
||||
|
||||
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed record SwarmUiDiscovery(
|
||||
bool Connected,
|
||||
IReadOnlyList<string> Models,
|
||||
IReadOnlyList<string> Loras,
|
||||
IReadOnlyList<string> Samplers,
|
||||
IReadOnlyList<string> Schedulers)
|
||||
{
|
||||
public static SwarmUiDiscovery Offline { get; } = new(false, [], [], [], []);
|
||||
}
|
||||
|
||||
internal static class SwarmUiDiscoveryParser
|
||||
{
|
||||
public static SwarmUiDiscovery Parse(JsonElement root)
|
||||
{
|
||||
var models = ReadModelNames(root, "Stable-Diffusion");
|
||||
var loras = ReadModelNames(root, "LoRA");
|
||||
var samplers = ReadParamValues(root, "sampler");
|
||||
var schedulers = ReadParamValues(root, "scheduler");
|
||||
return new SwarmUiDiscovery(true, models, loras, samplers, schedulers);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadModelNames(JsonElement root, string subtype)
|
||||
{
|
||||
if (!root.TryGetProperty("models", out var models) || models.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!models.TryGetProperty(subtype, out var list))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ReadStringList(list);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadParamValues(JsonElement root, string paramId)
|
||||
{
|
||||
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (var entry in list.EnumerateArray())
|
||||
{
|
||||
if (!entry.TryGetProperty("id", out var id) || id.GetString() != paramId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.TryGetProperty("values", out var values) || values.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ReadStringList(values);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringList(JsonElement list)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
switch (item.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
AddName(names, item.GetString());
|
||||
break;
|
||||
case JsonValueKind.Array when item.GetArrayLength() > 0:
|
||||
AddName(names, item[0].GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static void AddName(List<string> names, string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!names.Contains(name, StringComparer.Ordinal))
|
||||
{
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal static class SwarmUiLoraFormatter
|
||||
{
|
||||
/// <summary>SwarmUI comma-separated lora list: name,weight,name,weight,…</summary>
|
||||
public static string? FormatForApi(IReadOnlyList<SwarmUiLoraEntry> loras)
|
||||
{
|
||||
if (loras.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>(loras.Count * 2);
|
||||
foreach (var lora in loras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lora.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add(lora.Name.Trim());
|
||||
parts.Add(lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? null : string.Join(',', parts);
|
||||
}
|
||||
|
||||
public static string AppendLoraTags(string prompt, IReadOnlyList<SwarmUiLoraEntry> loras)
|
||||
{
|
||||
if (loras.Count == 0)
|
||||
{
|
||||
return prompt;
|
||||
}
|
||||
|
||||
var tags = loras
|
||||
.Where(lora => !string.IsNullOrWhiteSpace(lora.Name))
|
||||
.Select(lora =>
|
||||
$"<lora:{lora.Name.Trim()}:{lora.Weight.ToString(System.Globalization.CultureInfo.InvariantCulture)}>")
|
||||
.ToList();
|
||||
|
||||
if (tags.Count == 0)
|
||||
{
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(prompt)
|
||||
? string.Join(' ', tags)
|
||||
: $"{prompt.TrimEnd()} {string.Join(' ', tags)}";
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Generation defaults and prompt templates loaded from swarmui.json next to the server.</summary>
|
||||
internal sealed class SwarmUiSettings
|
||||
{
|
||||
public string Model { get; init; } = "";
|
||||
|
||||
public int Steps { get; init; } = 8;
|
||||
|
||||
public double CfgScale { get; init; } = 1;
|
||||
|
||||
public int ClipSkip { get; init; } = 1;
|
||||
|
||||
public string Sampler { get; init; } = "euler";
|
||||
|
||||
public string Scheduler { get; init; } = "";
|
||||
|
||||
public long Seed { get; init; } = -1;
|
||||
|
||||
public string Positive { get; init; } = "";
|
||||
|
||||
public string Negative { get; init; } = "";
|
||||
|
||||
public SwarmUiPreset Avatar { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset Custom { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset FullBody { get; init; } = new();
|
||||
|
||||
public SwarmUiPreset PresetFor(PortraitKind kind) => kind switch
|
||||
{
|
||||
PortraitKind.Avatar => Avatar,
|
||||
PortraitKind.Custom => Custom,
|
||||
PortraitKind.Full => FullBody,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
internal sealed class SwarmUiPreset
|
||||
{
|
||||
public int Width { get; init; } = 512;
|
||||
|
||||
public int Height { get; init; } = 512;
|
||||
|
||||
public string Positive { get; init; } = "";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger)
|
||||
{
|
||||
var path = Path.Combine(environment.ContentRootPath, "swarmui.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path);
|
||||
return new SwarmUiSettings();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<SwarmUiSettings>(json, Json) ?? new SwarmUiSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path);
|
||||
return new SwarmUiSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Loads, saves and serves SwarmUI generation presets from swarmui.json.</summary>
|
||||
internal sealed class SwarmUiSettingsStore(IHostEnvironment environment, ILogger<SwarmUiSettingsStore> logger)
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private SwarmUiConfigFile _config = SwarmUiConfigFile.CreateDefault();
|
||||
|
||||
public string FilePath { get; } = Path.Combine(environment.ContentRootPath, "swarmui.json");
|
||||
|
||||
public SwarmUiConfigFile Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_config = ReadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiConfigFile Save(SwarmUiConfigFile config)
|
||||
{
|
||||
config.Validate();
|
||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||
var temp = FilePath + ".tmp";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, FilePath, overwrite: true);
|
||||
lock (_lock)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
logger.LogInformation("SwarmUI settings saved to {Path}.", FilePath);
|
||||
return config;
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _config.Resolve(age, kind);
|
||||
}
|
||||
}
|
||||
|
||||
private SwarmUiConfigFile ReadFromDisk()
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
logger.LogWarning("SwarmUI settings file {Path} is missing; using built-in defaults.", FilePath);
|
||||
return SwarmUiConfigFile.CreateDefault();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
var loaded = JsonSerializer.Deserialize<SwarmUiConfigFile>(json, JsonOptions) ?? SwarmUiConfigFile.CreateDefault();
|
||||
loaded.NormalizeAfterLoad();
|
||||
loaded.Validate();
|
||||
return loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}; using built-in defaults.", FilePath);
|
||||
return SwarmUiConfigFile.CreateDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiConfigFile
|
||||
{
|
||||
public string ActivePresetId { get; set; } = "default";
|
||||
|
||||
public List<SwarmUiPresetDefinition> Presets { get; set; } = [];
|
||||
|
||||
public List<SwarmUiAgeRule> AgeRules { get; set; } = [];
|
||||
|
||||
// Legacy flat fields — read for migration only.
|
||||
public string? Model { get; set; }
|
||||
public int? Steps { get; set; }
|
||||
public double? CfgScale { get; set; }
|
||||
public int? ClipSkip { get; set; }
|
||||
public string? Sampler { get; set; }
|
||||
public string? Scheduler { get; set; }
|
||||
public long? Seed { get; set; }
|
||||
public string? Positive { get; set; }
|
||||
public string? Negative { get; set; }
|
||||
public SwarmUiKindPreset? Avatar { get; set; }
|
||||
public SwarmUiKindPreset? Custom { get; set; }
|
||||
public SwarmUiKindPreset? FullBody { get; set; }
|
||||
|
||||
public void NormalizeAfterLoad()
|
||||
{
|
||||
if (Presets.Count == 0 && !string.IsNullOrWhiteSpace(Model))
|
||||
{
|
||||
Presets =
|
||||
[
|
||||
new SwarmUiPresetDefinition
|
||||
{
|
||||
Id = "default",
|
||||
Label = "Default",
|
||||
Model = Model ?? "",
|
||||
Steps = Steps ?? 8,
|
||||
CfgScale = CfgScale ?? 1,
|
||||
ClipSkip = ClipSkip ?? 1,
|
||||
Sampler = Sampler ?? "",
|
||||
Scheduler = Scheduler ?? "",
|
||||
Seed = Seed ?? -1,
|
||||
Positive = Positive ?? "",
|
||||
Negative = Negative ?? "",
|
||||
Avatar = Avatar ?? new SwarmUiKindPreset(),
|
||||
Custom = Custom ?? new SwarmUiKindPreset(),
|
||||
FullBody = FullBody ?? new SwarmUiKindPreset(),
|
||||
},
|
||||
];
|
||||
ActivePresetId = "default";
|
||||
}
|
||||
|
||||
if (Presets.Count == 0)
|
||||
{
|
||||
Presets = [SwarmUiPresetDefinition.CreateDefault()];
|
||||
ActivePresetId = "default";
|
||||
}
|
||||
|
||||
foreach (var preset in Presets)
|
||||
{
|
||||
preset.Avatar ??= new SwarmUiKindPreset();
|
||||
preset.Custom ??= new SwarmUiKindPreset();
|
||||
preset.FullBody ??= new SwarmUiKindPreset();
|
||||
preset.PositiveLoras ??= [];
|
||||
preset.NegativeLoras ??= [];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ActivePresetId) || FindPreset(ActivePresetId) is null)
|
||||
{
|
||||
ActivePresetId = Presets[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Presets.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one preset is required.");
|
||||
}
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var preset in Presets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(preset.Id))
|
||||
{
|
||||
throw new InvalidOperationException("Every preset needs a non-empty id.");
|
||||
}
|
||||
|
||||
if (!ids.Add(preset.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate preset id '{preset.Id}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(preset.Label))
|
||||
{
|
||||
preset.Label = preset.Id;
|
||||
}
|
||||
|
||||
preset.Validate();
|
||||
}
|
||||
|
||||
if (FindPreset(ActivePresetId) is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Active preset '{ActivePresetId}' does not exist.");
|
||||
}
|
||||
|
||||
foreach (var rule in AgeRules)
|
||||
{
|
||||
if (rule.MinAge > rule.MaxAge)
|
||||
{
|
||||
throw new InvalidOperationException($"Age rule {rule.MinAge}-{rule.MaxAge} is inverted.");
|
||||
}
|
||||
|
||||
if (FindPreset(rule.PresetId) is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Age rule references unknown preset '{rule.PresetId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile Resolve(int age, PortraitKind kind)
|
||||
{
|
||||
var presetId = ResolvePresetId(age);
|
||||
var preset = FindPreset(presetId) ?? FindPreset(ActivePresetId) ?? Presets[0];
|
||||
return preset.ToProfile(kind);
|
||||
}
|
||||
|
||||
private string ResolvePresetId(int age)
|
||||
{
|
||||
foreach (var rule in AgeRules.OrderBy(rule => rule.MinAge))
|
||||
{
|
||||
if (age >= rule.MinAge && age <= rule.MaxAge)
|
||||
{
|
||||
return rule.PresetId;
|
||||
}
|
||||
}
|
||||
|
||||
return ActivePresetId;
|
||||
}
|
||||
|
||||
private SwarmUiPresetDefinition? FindPreset(string id) =>
|
||||
Presets.FirstOrDefault(preset => string.Equals(preset.Id, id, StringComparison.Ordinal));
|
||||
|
||||
public static SwarmUiConfigFile CreateDefault() =>
|
||||
new()
|
||||
{
|
||||
ActivePresetId = "default",
|
||||
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
|
||||
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiPresetDefinition
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
public string Model { get; set; } = "";
|
||||
|
||||
public int Steps { get; set; } = 8;
|
||||
|
||||
public double CfgScale { get; set; } = 1;
|
||||
|
||||
public int ClipSkip { get; set; } = 1;
|
||||
|
||||
public string Sampler { get; set; } = "";
|
||||
|
||||
public string Scheduler { get; set; } = "";
|
||||
|
||||
public long Seed { get; set; } = -1;
|
||||
|
||||
public string Positive { get; set; } = "";
|
||||
|
||||
public string Negative { get; set; } = "";
|
||||
|
||||
public List<SwarmUiLoraEntry>? PositiveLoras { get; set; }
|
||||
|
||||
public List<SwarmUiLoraEntry>? NegativeLoras { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? Avatar { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? Custom { get; set; }
|
||||
|
||||
public SwarmUiKindPreset? FullBody { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Steps is < 1 or > 200)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' steps must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (CfgScale is < 0 or > 30)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' cfgScale must be between 0 and 30.");
|
||||
}
|
||||
|
||||
if (ClipSkip is < 0 or > 12)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' clipSkip must be between 0 and 12.");
|
||||
}
|
||||
|
||||
ValidateLoras(PositiveLoras, "positive");
|
||||
ValidateLoras(NegativeLoras, "negative");
|
||||
}
|
||||
|
||||
private void ValidateLoras(IReadOnlyList<SwarmUiLoraEntry>? loras, string side)
|
||||
{
|
||||
if (loras is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var lora in loras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lora.Name))
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' has an empty {side} LoRA name.");
|
||||
}
|
||||
|
||||
if (lora.Weight is < -4 or > 4)
|
||||
{
|
||||
throw new InvalidOperationException($"Preset '{Id}' LoRA '{lora.Name}' weight is out of range.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SwarmUiResolvedProfile ToProfile(PortraitKind kind) =>
|
||||
new(
|
||||
Id,
|
||||
Label,
|
||||
Model,
|
||||
Steps,
|
||||
CfgScale,
|
||||
ClipSkip,
|
||||
Sampler,
|
||||
Scheduler,
|
||||
Seed,
|
||||
Positive,
|
||||
Negative,
|
||||
PositiveLoras ?? [],
|
||||
NegativeLoras ?? [],
|
||||
KindPresetFor(kind));
|
||||
|
||||
private SwarmUiKindPreset KindPresetFor(PortraitKind kind) => kind switch
|
||||
{
|
||||
PortraitKind.Avatar => Avatar ?? new SwarmUiKindPreset(),
|
||||
PortraitKind.Custom => Custom ?? new SwarmUiKindPreset(),
|
||||
PortraitKind.Full => FullBody ?? new SwarmUiKindPreset(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
public static SwarmUiPresetDefinition CreateDefault() =>
|
||||
new()
|
||||
{
|
||||
Id = "default",
|
||||
Label = "Default",
|
||||
Model = "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
Steps = 4,
|
||||
CfgScale = 2,
|
||||
ClipSkip = 2,
|
||||
Sampler = "dpmpp_sde",
|
||||
Scheduler = "karras",
|
||||
Seed = 3346112079,
|
||||
Positive =
|
||||
"cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
Negative =
|
||||
"(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
Avatar = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 1024,
|
||||
Height = 1024,
|
||||
Positive = "close up, head and shoulders portrait, facing the camera, upper body visible.",
|
||||
},
|
||||
Custom = new SwarmUiKindPreset { Width = 896, Height = 1152 },
|
||||
FullBody = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 896,
|
||||
Height = 1152,
|
||||
Positive = "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible.",
|
||||
},
|
||||
};
|
||||
|
||||
public static SwarmUiPresetDefinition CreateChild()
|
||||
{
|
||||
var adult = CreateDefault();
|
||||
return new SwarmUiPresetDefinition
|
||||
{
|
||||
Id = "child",
|
||||
Label = "Children",
|
||||
Model = adult.Model,
|
||||
Steps = adult.Steps,
|
||||
CfgScale = adult.CfgScale,
|
||||
ClipSkip = adult.ClipSkip,
|
||||
Sampler = adult.Sampler,
|
||||
Scheduler = adult.Scheduler,
|
||||
Seed = adult.Seed,
|
||||
Positive =
|
||||
"cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
|
||||
Negative = adult.Negative,
|
||||
Avatar = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 1024,
|
||||
Height = 1024,
|
||||
Positive = "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features.",
|
||||
},
|
||||
Custom = adult.Custom,
|
||||
FullBody = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 896,
|
||||
Height = 1152,
|
||||
Positive = "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiAgeRule
|
||||
{
|
||||
public int MinAge { get; set; }
|
||||
|
||||
public int MaxAge { get; set; }
|
||||
|
||||
public string PresetId { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiKindPreset
|
||||
{
|
||||
public int Width { get; set; } = 512;
|
||||
|
||||
public int Height { get; set; } = 512;
|
||||
|
||||
public string Positive { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiLoraEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public double Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
internal sealed record SwarmUiResolvedProfile(
|
||||
string PresetId,
|
||||
string PresetLabel,
|
||||
string Model,
|
||||
int Steps,
|
||||
double CfgScale,
|
||||
int ClipSkip,
|
||||
string Sampler,
|
||||
string Scheduler,
|
||||
long Seed,
|
||||
string Positive,
|
||||
string Negative,
|
||||
IReadOnlyList<SwarmUiLoraEntry> PositiveLoras,
|
||||
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
|
||||
SwarmUiKindPreset KindPreset);
|
||||
@@ -38,7 +38,14 @@ builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
builder.Services.AddSwarmUi(builder.Configuration);
|
||||
builder.Services.AddSingleton<SwarmUiHealthService>();
|
||||
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
|
||||
builder.Services.AddSingleton<SwarmUiSettingsStore>(sp =>
|
||||
{
|
||||
var store = new SwarmUiSettingsStore(
|
||||
sp.GetRequiredService<IHostEnvironment>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<SwarmUiSettingsStore>());
|
||||
store.Load();
|
||||
return store;
|
||||
});
|
||||
builder.Services.AddSingleton<PortraitService>();
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
|
||||
@@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions
|
||||
});
|
||||
|
||||
app.MapSchoolEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapTimetableEndpoints();
|
||||
app.MapModEndpoints();
|
||||
|
||||
|
||||
@@ -1,25 +1,72 @@
|
||||
{
|
||||
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "dpmpp_sde",
|
||||
"scheduler": "karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
}
|
||||
"activePresetId": "default",
|
||||
"presets": [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
|
||||
"steps": 10,
|
||||
"cfgScale": 1,
|
||||
"clipSkip": 2,
|
||||
"sampler": "euler",
|
||||
"scheduler": "beta",
|
||||
"seed": 0,
|
||||
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, ",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, explicit, blurry, deformed, bad anatomy, logo",
|
||||
"positiveLoras": [],
|
||||
"negativeLoras": [],
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": ""
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "child",
|
||||
"label": "Children",
|
||||
"model": "DreamShaper_XL_-_Lightning_DPM\u002B\u002B_SDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "dpmpp_sde",
|
||||
"scheduler": "karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, child-friendly school portrait, soft natural features, gentle expression, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
"positiveLoras": [],
|
||||
"negativeLoras": [],
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"positive": "close up, head and shoulders portrait of a child, facing the camera, upper body visible, soft features."
|
||||
},
|
||||
"custom": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": ""
|
||||
},
|
||||
"fullBody": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"positive": "full body standing portrait of a child, head to toe visible, neutral pose, current outfit clearly visible."
|
||||
}
|
||||
}
|
||||
],
|
||||
"ageRules": [
|
||||
{
|
||||
"minAge": 6,
|
||||
"maxAge": 11,
|
||||
"presetId": "child"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -54,6 +54,38 @@ public class DressRulesApiTests(AppHostFixture fixture)
|
||||
Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownForm_Returns400()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Плохая форма", Start, seed: 35);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"/api/schools/{school.Id}/dress-rules",
|
||||
new { students = new { form = "tuxedo", color = "noBright" } },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("unknown-form", await SchoolApiTests.ProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownColor_Returns400()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Плохой цвет", Start, seed: 35);
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"/api/schools/{school.Id}/dress-rules",
|
||||
new { students = new { form = "regular", color = "neon" } },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("unknown-color", await SchoolApiTests.ProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff);
|
||||
|
||||
private sealed record DressRulePairDto(string Form, string Color);
|
||||
|
||||
@@ -86,6 +86,20 @@ public class PortraitApiTests(AppHostFixture fixture)
|
||||
Assert.Equal("avatar", payload!.Kind);
|
||||
Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.PresetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSwarmUiSettings_ReturnsPresets()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
|
||||
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
|
||||
"/api/settings/swarmui",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(settings);
|
||||
Assert.NotEmpty(settings!.Presets);
|
||||
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,7 +148,17 @@ public class PortraitApiTests(AppHostFixture fixture)
|
||||
bool HasFullBody,
|
||||
string? CustomPortraitPrompt);
|
||||
|
||||
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra);
|
||||
private sealed record PortraitPromptPayload(
|
||||
string Kind,
|
||||
string Positive,
|
||||
string Negative,
|
||||
string? PromptExtra,
|
||||
string PresetId,
|
||||
string PresetLabel);
|
||||
|
||||
private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList<SwarmUiPresetPayload> Presets);
|
||||
|
||||
private sealed record SwarmUiPresetPayload(string Id, string Label);
|
||||
|
||||
private sealed record SavesDirectoryResponse(string Path);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ public class AppropriatenessTests
|
||||
Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteTopBlackBottom_RejectsWrongTopOrBottom()
|
||||
{
|
||||
var catalog = LoadCatalog();
|
||||
var rules = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom);
|
||||
Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "Gray"));
|
||||
Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "White"));
|
||||
Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Blue"));
|
||||
Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Black"));
|
||||
}
|
||||
|
||||
private static DefCatalog LoadCatalog()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||
|
||||
@@ -8,14 +8,10 @@ public class PortraitPromptBuilderTests
|
||||
[Fact]
|
||||
public void Build_IncludesAgeHairAndWornClothing()
|
||||
{
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Positive = "School photo.",
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
|
||||
};
|
||||
var profile = SamplePreset().ToProfile(PortraitKind.Avatar);
|
||||
|
||||
var card = SampleCard();
|
||||
var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar);
|
||||
var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar);
|
||||
|
||||
Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -27,15 +23,9 @@ public class PortraitPromptBuilderTests
|
||||
[Fact]
|
||||
public void Build_FullBody_AddsFullBodyPreset()
|
||||
{
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Positive = "School photo.",
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
|
||||
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
|
||||
};
|
||||
|
||||
var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar);
|
||||
var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full);
|
||||
var preset = SamplePreset();
|
||||
var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar);
|
||||
var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Full), PortraitKind.Full);
|
||||
|
||||
Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal);
|
||||
@@ -45,17 +35,11 @@ public class PortraitPromptBuilderTests
|
||||
[Fact]
|
||||
public void Build_Custom_UsesPromptExtraInsteadOfPreset()
|
||||
{
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Positive = "School photo.",
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
|
||||
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
|
||||
};
|
||||
|
||||
var profile = SamplePreset().ToProfile(PortraitKind.Custom);
|
||||
const string extra = "standing in a school hallway, soft window light";
|
||||
var (customPositive, _) = PortraitPromptBuilder.Build(
|
||||
SampleCard(),
|
||||
settings,
|
||||
profile,
|
||||
PortraitKind.Custom,
|
||||
extra);
|
||||
|
||||
@@ -65,6 +49,35 @@ public class PortraitPromptBuilderTests
|
||||
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ChildAge_UsesChildSubjectDescriptor()
|
||||
{
|
||||
var profile = SamplePreset().ToProfile(PortraitKind.Avatar);
|
||||
var child = SampleCard() with { Age = 8 };
|
||||
var (positive, _) = PortraitPromptBuilder.Build(child, profile, PortraitKind.Avatar);
|
||||
|
||||
Assert.Contains("young girl", positive, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SwarmUiPresetDefinition SamplePreset()
|
||||
{
|
||||
var preset = SwarmUiPresetDefinition.CreateDefault();
|
||||
preset.Positive = "School photo.";
|
||||
preset.Avatar = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 512,
|
||||
Height = 512,
|
||||
Positive = "Head and shoulders.",
|
||||
};
|
||||
preset.FullBody = new SwarmUiKindPreset
|
||||
{
|
||||
Width = 512,
|
||||
Height = 768,
|
||||
Positive = "Standing full body.",
|
||||
};
|
||||
return preset;
|
||||
}
|
||||
|
||||
private static PersonCardResponse SampleCard() =>
|
||||
new(
|
||||
"f0.c0",
|
||||
|
||||
@@ -22,15 +22,10 @@ public class SwarmUiClientTests
|
||||
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
|
||||
NullLogger<SwarmUiClient>.Instance);
|
||||
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Model = "model.safetensors",
|
||||
Steps = 8,
|
||||
CfgScale = 1,
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
|
||||
};
|
||||
var preset = SwarmUiPresetDefinition.CreateDefault();
|
||||
var profile = preset.ToProfile(PortraitKind.Avatar);
|
||||
|
||||
var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None);
|
||||
var bytes = await client.GenerateAsync("a student", "bad", profile, CancellationToken.None);
|
||||
|
||||
Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4));
|
||||
Assert.Contains("/API/GetNewSession", handler.Requests[0]);
|
||||
@@ -85,18 +80,15 @@ public class SwarmUiClientTests
|
||||
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
|
||||
NullLogger<SwarmUiClient>.Instance);
|
||||
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Model = "model.safetensors",
|
||||
Steps = 4,
|
||||
CfgScale = 2,
|
||||
ClipSkip = 2,
|
||||
Sampler = "dpmpp_sde",
|
||||
Scheduler = "karras",
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
|
||||
};
|
||||
var preset = SwarmUiPresetDefinition.CreateDefault();
|
||||
preset.Steps = 4;
|
||||
preset.CfgScale = 2;
|
||||
preset.ClipSkip = 2;
|
||||
preset.Sampler = "dpmpp_sde";
|
||||
preset.Scheduler = "karras";
|
||||
var profile = preset.ToProfile(PortraitKind.Avatar);
|
||||
|
||||
await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None);
|
||||
await client.GenerateAsync("a student", "bad", profile, CancellationToken.None);
|
||||
|
||||
using var document = JsonDocument.Parse(handler.GenerateBody!);
|
||||
Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32());
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using HSchool.Server.Game;
|
||||
|
||||
namespace HSchool.Server.Tests;
|
||||
|
||||
public class SwarmUiSettingsStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_UsesAgeRulePreset()
|
||||
{
|
||||
var config = SwarmUiConfigFile.CreateDefault();
|
||||
var profile = config.Resolve(8, PortraitKind.Avatar);
|
||||
|
||||
Assert.Equal("child", profile.PresetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FallsBackToActivePresetForAdults()
|
||||
{
|
||||
var config = SwarmUiConfigFile.CreateDefault();
|
||||
var profile = config.Resolve(34, PortraitKind.Full);
|
||||
|
||||
Assert.Equal("default", profile.PresetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeAfterLoad_MigratesLegacyFlatFile()
|
||||
{
|
||||
var config = new SwarmUiConfigFile
|
||||
{
|
||||
Model = "legacy.safetensors",
|
||||
Steps = 12,
|
||||
Positive = "legacy prompt",
|
||||
Avatar = new SwarmUiKindPreset { Width = 640, Height = 640 },
|
||||
};
|
||||
|
||||
config.NormalizeAfterLoad();
|
||||
|
||||
Assert.Single(config.Presets);
|
||||
Assert.Equal("default", config.Presets[0].Id);
|
||||
Assert.Equal("legacy.safetensors", config.Presets[0].Model);
|
||||
Assert.Equal(12, config.Presets[0].Steps);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,63 @@ public class AppropriatenessSimulationTests
|
||||
Assert.Equal(0, BrightWornCount(school));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingNoBright_AfterMorning_RemovesBrightWornColours()
|
||||
{
|
||||
using var school = Open(TuesdayMorning, seed: 31);
|
||||
MorningDress.Apply(school);
|
||||
school.DressRules = school.DressRules with
|
||||
{
|
||||
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.Free),
|
||||
};
|
||||
AdvanceMorning(school);
|
||||
Assert.True(BrightWornCount(school) > 0, "Free colours should allow at least one bright item.");
|
||||
|
||||
school.DressRules = school.DressRules with
|
||||
{
|
||||
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright),
|
||||
};
|
||||
AdvanceMorning(school);
|
||||
Assert.Equal(0, BrightWornCount(school));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteTopBlackBottom_AfterMorning_WhiteShirtsOnStudents()
|
||||
{
|
||||
using var school = Open(TuesdayMorning, seed: 31);
|
||||
MorningDress.Apply(school);
|
||||
school.DressRules = school.DressRules with
|
||||
{
|
||||
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom),
|
||||
};
|
||||
AdvanceMorning(school);
|
||||
|
||||
var checkedAny = false;
|
||||
foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent))
|
||||
{
|
||||
foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn))
|
||||
{
|
||||
if (!school.Catalog!.Things[item.Def].Layers.Any(layer =>
|
||||
layer.Equals(ApparelLayers.Top, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedAny = true;
|
||||
Assert.Equal("White", item.Color);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(checkedAny, "Expected at least one pupil with a top-layer shirt.");
|
||||
}
|
||||
|
||||
private static void AdvanceMorning(School school)
|
||||
{
|
||||
var before = school.Clock.Time;
|
||||
school.Clock.JumpTo(before.Date.AddDays(1).AddHours(6));
|
||||
ApparelWear.Apply(school, gameMinutes: 0, before);
|
||||
}
|
||||
|
||||
private static int BrightWornCount(School school)
|
||||
{
|
||||
var count = 0;
|
||||
|
||||
Reference in New Issue
Block a user