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:
Leonid Pershin
2026-08-20 06:46:36 +03:00
44 changed files with 3009 additions and 232 deletions
+1
View File
@@ -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 2937; country and wardrobes are live, weather is not | | clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 2937; 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 3839 | | login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 3839 |
| clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 | | 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 4147 |
## Commands ## Commands
+299
View File
@@ -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 — разговоры
### Кружок
В одном узле собирается **24** человека, одна тема на всех. Пятый начинает соседний кружок,
не влезает в этот.
Кого зовут: сначала друзья и одноклассники в узле, потом остальные. Врагов сами не зовут —
столкновение может уйти в ссору. Тихоня реже инициатор, лидер чаще. Веса — поля на `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 -12
View File
@@ -12,24 +12,24 @@
## Задачи ## Задачи
- [ ] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим - [x] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим
- [ ] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников. - [x] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников.
Значения — как в [`../design/inventory.md`](../design/inventory.md) Значения — как в [`../design/inventory.md`](../design/inventory.md)
- [ ] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что - [x] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что
вступит завтра вступит завтра
- [ ] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила — - [x] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила —
`400`, не тишина `400`, не тишина
- [ ] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него - [x] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него
- [ ] Строки через `t(...)`, обе локали - [x] Строки через `t(...)`, обе локали
- [ ] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом - [x] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] GET правил на новой школе — дефолт обычная / без яркого на обе роли - [x] GET правил на новой школе — дефолт обычная / без яркого на обе роли
- [ ] POST «строгая» сегодня не меняет одежду до следующего рабочего утра - [x] POST «строгая» сегодня не меняет одежду до следующего рабочего утра
- [ ] После утра на теле учеников нет яркого, если отложили «без яркого» - [x] После утра на теле учеников нет яркого, если отложили «без яркого»
- [ ] Неизвестный код политики — `400` - [x] Неизвестный код политики — `400`
- [ ] Вкладка не ломает наём и сетку расписания - [x] Вкладка не ломает наём и сетку расписания
## Критерий готовности ## Критерий готовности
+42
View File
@@ -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` проходят
## Стоп
Не собирать кружки. Не бампить сокет. Не заводить романтику.
+49
View File
@@ -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` проходит
## Стоп
Не шёпот на уроке. Не драка. Не менять кадр присутствия (локация может ещё писать «Разговор»).
+38
View File
@@ -0,0 +1,38 @@
# Фаза 43. Шёпот и учитель
## Зависимости
- [Фаза 42](42-talk-circles.md)
## Зачем
На уроке можно шептаться — ценой учёбы. Учитель иногда слышит. После урока учитель сам
говорит с учениками.
## Задачи
- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса
- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`)
- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 %
- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у
строгого разговора
- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели
- [ ] Сдвиг мнения ученика об учителе масштабируется педагогикой (поле навыка, не фамилия)
- [ ] Лог: «шептались», «учитель оборвал»
## Тесты, без которых фаза не закрыта
- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка
- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается
- [ ] Без учителя в узле шёпот не обрывается этим правилом
- [ ] Учитель с высокой педагогикой после похвалы сильнее плюсует мнение, чем с низкой
- [ ] Обычный не-шёпотный `Chat` на уроке не стартует
## Критерий готовности
- На математике двое шепчутся, иногда учитель обрывает, в логе это видно
- `dotnet test` проходит
## Стоп
Не драка. Не политика речи в UI. Не протокол.
+40
View File
@@ -0,0 +1,40 @@
# Фаза 44. Ссора и драка
## Зависимости
- [Фаза 42](42-talk-circles.md)
## Зачем
Вражда — не только минус в JSON. На перемене ссорятся, на дворе или физкультуре редко дерутся.
Травм нет.
## Задачи
- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора
- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта
- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName
- [ ] Задира держит id жертвы, пока та в ростере; не меняет цель каждый день
- [ ] Третий с высоким плюсом к жертве может вступить в ссору на её стороне
- [ ] Действие «извиниться» после ссоры/выговора в одном узле возвращает часть мнения
- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается
- [ ] Лог обоих; кабинет директора не заводим
- [ ] Игрок не приказывает разнять
## Тесты, без которых фаза не закрыта
- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей
- [ ] Задира два дня подряд целится в того же человека, если тот ещё в школе
- [ ] Извинение поднимает мнение, но не выше, чем до ссоры
- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется
- [ ] Выговор после обрыва есть, если в узле был сотрудник
- [ ] Skip пустого времени не оставляет «вечную драку» в состоянии
## Критерий готовности
- Задира с врагом на дворе: в логе ссора или драка, мнения ниже, оба живы и на ногах
- `dotnet test` проходит
## Стоп
Не вызов родителей. Не травмы. Не романтика.
+34
View File
@@ -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` проходят
## Стоп
Не политика речи. Не пак.
+38
View File
@@ -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` проходят
## Стоп
Не романтика. Не вызов родителей в школу.
+46
View File
@@ -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
View File
@@ -149,7 +149,7 @@
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
| [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура | | [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура |
| [36. Вкладки карточки](36-person-card-tabs.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 в части вкладок одежды, 34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды,
история — после 35; 37 стоит на 35 и 36. история — после 35; 37 стоит на 35 и 36.
@@ -182,3 +182,43 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [40. Темп часов](40-clock-tempo.md) | ⬜ | ×1 = минута/с, кнопки ×½ ×1 ×2 ×5 ×10, страйд тяжёлых систем | | [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
View File
@@ -256,50 +256,100 @@
- `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`. - `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`.
Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5. Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5.
- Срез 7, фаза 37 — в работе на момент проверки B–C; этап A проверяет соседний ревьюер.
## Срез 7. Этап B–C — климат и жизнь одежды (без 37) ## Срез 7. Этап A — данные и гардероб
- **Фазы:** 3236 - **Фазы:** 2931
- **Проверен на:** `e461a9f`, 2026-08-20 - **Проверен на:** `d5547b7`, 2026-08-20
- **Пути:** `src/HSchool.Simulation/{WeatherSampler,WarmthDecay,PlaceClimate,ApparelDresser,ApparelWear,MorningDress,PersonLogBrowser}.cs`, - **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`,
`src/HSchool.People/LockerAssigner.cs`, `src/HSchool.Content/{Appropriateness,ClimatePreset,DressRules}.cs`, `src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`,
`src/HSchool.Ai/ApparelGoals.cs`, `src/HSchool.Protocol`, `src/HSchool.Server/Api/{SchoolEndpoints,PeopleModels}.cs`, `src/HSchool.Server/{Game/SchoolStore.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`,
`src/HSchool.Server/Game/PersonCardReader.cs`, `src/HSchool.Client/src/{ui/personCard.ts,format/weather.ts,net/api.ts}`, `src/HSchool.Client/src/ui/{createSchoolDialog.ts,personCard.ts}`,
`docs/protocol.md`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.People.Tests/LockerAssignerTests.cs`, `tests/HSchool.Content.Tests/{CountryDefTests,ApparelDefTests,VanillaCoreTests}.cs`,
`tests/HSchool.Content.Tests/{ClimatePresetTests,AppropriatenessTests,ChangingRoomMapTests}.cs`, `tests/HSchool.People.Tests/{DressGeneratorTests,GoldenRosterTests}.cs`,
`tests/HSchool.AppHost.Tests/{PersonLogApiTests,DressRulesApiTests}.cs`, `tests/HSchool.Protocol.Tests`, `tests/HSchool.AppHost.Tests/{SchoolApiTests,InventoryApiTests,GoldenSaveTests,ServiceabilityTests}.cs`,
`src/HSchool.Client/src/ui/personCard.test.ts` `src/HSchool.Client/src/ui/createSchoolDialog.test.ts`
- **Итог:** фазы 32–36 подтверждены; дописано 5 тестов; исправлены 2 бага переодевания; фаза 37 в работе - **Итог:** все 17 тестов из списков фаз на месте; исправлен дубликат `peopleTabOverview` в
`strings.ts` (ломал `npm run build`); одно замечание рядом про вкладки карточки
Что подтверждено: Что подтверждено:
- Погода детерминирована от пресета, сида и времени; skip выставляет утро; кадр часов v8 (27 байт) в - **29:** `CountryDef`/`ClimatePresetDef`; `namesets/` убран; ваниль `Russia` с двумя пресетами;
`ProtocolCodec`, `protocol.ts`, `docs/protocol.md`; клиент рисует температуру с wire, не из месяца. каталог отдаёт `countries`; create принимает `countryId`, `unknown-country` на чужую страну;
- `Warmth` декейит на морозе, дома восстанавливается; черты `HeatLoving`/`ColdLoving` несовместимы. пресет от сида (`CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset`); `nameSetId: Slavic`
- Техник держит комфорт; двор = улица, кабинет теплее двора без техника; две раздевалки на ванили; поднимается через `UpgradeOlderSave` (`GoldenSaveTests`, `legacy-no-native/`); формат без
шкафчики по полу/старшинству, пересчёт на 1 сентября. страны не стартует (`ServiceabilityTests`); диалог — страны и языки (`createSchoolDialog.test.ts`);
- Износ только на кампусе, skip/утро заменяют ветхое; утренняя выдача пишет `apparel-replaced`. золотой ростер обновлён (`GoldenRosterTests`).
- Уместность: утро, PE, погода, правила «с завтра» через API; карточка — вкладки и HTTP-лог с 6:00. - **30:** поля `ThingDef`/`ColorDef`, слои как роли, мебель без `layers`; ванильная палитра и
- `HSchool.Simulation` без ASP.NET; `dotnet test` и Vitest (`personCard`, `weather`, `protocol`) зелёные. одежда в `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 — климат, жизнь одежды и правила
- **Фазы:** 3237
- **Проверен на:** `<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.MorningDress_StagesPeKitForTodaysLesson`
- `AppropriatenessSimulationTests.PeBreak_CompletesChangeBeforeLesson` - `AppropriatenessSimulationTests.PeBreak_CompletesChangeBeforeLesson`
- `AppropriatenessSimulationTests.PeLesson_WearsPeThenReturnsToEveryday` - `AppropriatenessSimulationTests.PeLesson_WearsPeThenReturnsToEveryday`
- `AppropriatenessSimulationTests.AfterPeBreak_ModeExpectsEveryday` - `AppropriatenessSimulationTests.AfterPeBreak_ModeExpectsEveryday`
- `PresenceTests.PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak` (переименован с ожидания зала на перемене) - `PresenceTests.PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak`
Исправлено: Исправлено:
- `PresenceSystem`: ходьба по duty больше не обрывает активный `ChangeClothes` — форма успевает надеться до - `PresenceSystem`: ходьба по duty не обрывает активный `ChangeClothes`.
звонка. - `ApparelPresence.ModeAt`: после перемены following PE — режим everyday.
- `ApparelPresence.ModeAt`: после перемены following PE режим «everyday», иначе `PeForbidden` не - `managementPanel.test.ts`: панель штата скрывается через `hidden`, не удаляется из DOM.
поднимался и форму не снимали. - `PortraitApiTests.GetSwarmUiSettings_ReturnsPresets`: URL `/api/settings/swarmui` (CS1503).
- `dressRulesPanel.ts`: неиспользуемый импорт `clear` — TS6133 на `npm run build`.
Замечено рядом: Замечено рядом:
- Фаза 37 (вкладка «Правила», UI смены дресс-кода) — в работе, не проверялась. - `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в
- `PhysicalEducationBeforeLesson_…` в `PresenceTests` не гоняет полный цикл «зал с формой» — это в
`AppropriatenessSimulationTests.PeLesson_…`. `AppropriatenessSimulationTests.PeLesson_…`.
- Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала.
+20 -1
View File
@@ -389,13 +389,32 @@ is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `ki
"kind": "avatar", "kind": "avatar",
"positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …", "positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …",
"negative": "(low quality, worst quality:1.4), …", "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) `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. 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` ### `GET /api/schools/{id}/dress-rules`
Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`. Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`.
+130 -4
View File
@@ -11,6 +11,44 @@ const ru = {
schoolsTitle: 'Школы', schoolsTitle: 'Школы',
createSchool: 'Создать школу', 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: 'Пока ни одной школы. Создайте первую.', emptySchools: 'Пока ни одной школы. Создайте первую.',
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.', loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую', createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
@@ -140,6 +178,7 @@ const ru = {
peopleTabApparel: 'Одежда', peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша', peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас', peopleTabNow: 'Сейчас',
peopleTabPortrait: 'Портрет',
peopleAtHome: 'дома', peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}', peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик', peopleLockerYes: 'Есть шкафчик',
@@ -152,8 +191,6 @@ const ru = {
peopleLogEvent: 'Событие', peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.', peopleLogEmpty: 'Сегодня записей нет.',
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар', peoplePortraitAvatar: 'Аватар',
peoplePortraitFull: 'В полный рост', peoplePortraitFull: 'В полный рост',
peoplePortraitCustom: 'Свой промпт', peoplePortraitCustom: 'Свой промпт',
@@ -178,6 +215,7 @@ const ru = {
peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Сборка промпта…', peoplePortraitPromptLoading: 'Сборка промпта…',
peoplePortraitPromptFailed: 'Не удалось получить промпт.', peoplePortraitPromptFailed: 'Не удалось получить промпт.',
peoplePortraitPromptPreset: 'Пресет: {label}',
modeOverview: 'Обзор', modeOverview: 'Обзор',
modeManage: 'Управление', modeManage: 'Управление',
@@ -232,6 +270,31 @@ const ru = {
staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorAssigned: 'Этот предмет уже назначен.',
staffErrorSubject: 'Такого предмета нет.', 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})', mapOccupancy: '{name} ({activity})',
mapHeadcount: '{name} ({count})', mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})', mapHeadcountActivity: '{name} ({count} · {activity})',
@@ -277,6 +340,44 @@ const en: Messages = {
schoolsTitle: 'Schools', schoolsTitle: 'Schools',
createSchool: 'Create school', 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.', emptySchools: 'No schools yet. Create the first one.',
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.', loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
createDisabledTitle: 'Delete a school to create a new one', createDisabledTitle: 'Delete a school to create a new one',
@@ -406,6 +507,7 @@ const en: Messages = {
peopleTabApparel: 'Clothes', peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried', peopleTabCarry: 'Carried',
peopleTabNow: 'Now', peopleTabNow: 'Now',
peopleTabPortrait: 'Portrait',
peopleAtHome: 'home', peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}', peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker', peopleLockerYes: 'Has a locker',
@@ -418,8 +520,6 @@ const en: Messages = {
peopleLogEvent: 'Event', peopleLogEvent: 'Event',
peopleLogEmpty: 'Nothing logged today.', peopleLogEmpty: 'Nothing logged today.',
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar', peoplePortraitAvatar: 'Avatar',
peoplePortraitFull: 'Full body', peoplePortraitFull: 'Full body',
peoplePortraitCustom: 'Custom prompt', peoplePortraitCustom: 'Custom prompt',
@@ -444,6 +544,7 @@ const en: Messages = {
peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Building prompt…', peoplePortraitPromptLoading: 'Building prompt…',
peoplePortraitPromptFailed: 'Could not load the prompt.', peoplePortraitPromptFailed: 'Could not load the prompt.',
peoplePortraitPromptPreset: 'Preset: {label}',
modeOverview: 'Overview', modeOverview: 'Overview',
modeManage: 'Management', modeManage: 'Management',
@@ -498,6 +599,31 @@ const en: Messages = {
staffErrorAssigned: 'That subject is already assigned.', staffErrorAssigned: 'That subject is already assigned.',
staffErrorSubject: 'That subject is not in the catalog.', 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})', mapOccupancy: '{name} ({activity})',
mapHeadcount: '{name} ({count})', mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})', mapHeadcountActivity: '{name} ({count} · {activity})',
+95
View File
@@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status'); 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 type PortraitKind = 'avatar' | 'full' | 'custom';
export interface PortraitResult { export interface PortraitResult {
@@ -430,6 +496,8 @@ export interface PortraitPrompt {
readonly positive: string; readonly positive: string;
readonly negative: string; readonly negative: string;
readonly promptExtra: string | null; readonly promptExtra: string | null;
readonly presetId: string;
readonly presetLabel: string;
} }
export async function fetchPortraitPrompt( 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 { export interface TimetableLesson {
readonly classId: string; readonly classId: string;
readonly classYear: number; readonly classYear: number;
+51 -1
View File
@@ -683,10 +683,45 @@ body {
overflow-x: auto; 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); color: var(--text-muted, #666);
} }
.field--grow {
flex: 1;
}
.people__swarm-status { .people__swarm-status {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 13px; font-size: 13px;
@@ -1012,6 +1047,21 @@ body {
font-size: 13px; 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 { .staffing__badge {
margin-left: 8px; margin-left: 8px;
padding: 1px 6px; 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;
}
+57
View File
@@ -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');
}
}
+8 -1
View File
@@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts'; import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
import { SchoolCard } from './schoolCard.ts'; import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions { interface MainMenuOptions {
@@ -35,6 +36,10 @@ export class MainMenu {
class: 'button button--primary', class: 'button button--primary',
type: 'button', type: 'button',
}); });
private readonly settingsButton = el('button', {
class: 'button',
type: 'button',
});
private readonly limitHint = el('p', { class: 'hint' }); private readonly limitHint = el('p', { class: 'hint' });
private readonly status = el('p', { class: 'hint hint--error' }); private readonly status = el('p', { class: 'hint hint--error' });
@@ -47,6 +52,7 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) { constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog()); this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.status.hidden = true; this.status.hidden = true;
this.root.append( this.root.append(
@@ -54,7 +60,7 @@ export class MainMenu {
'header', 'header',
{ class: 'screen__header' }, { class: 'screen__header' },
this.title, this.title,
el('div', { class: 'screen__actions' }, this.createButton), el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
), ),
this.limitHint, this.limitHint,
this.status, this.status,
@@ -73,6 +79,7 @@ export class MainMenu {
localize(): void { localize(): void {
this.title.textContent = t('schoolsTitle'); this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool'); this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings');
this.emptyHint.textContent = t('emptySchools'); this.emptyHint.textContent = t('emptySchools');
for (const card of this.cards.values()) { for (const card of this.cards.values()) {
@@ -6,6 +6,7 @@ import {
ApiError, ApiError,
fetchPerson, fetchPerson,
fetchGameStatus, fetchGameStatus,
fetchDressRules,
fetchStaffing, fetchStaffing,
fetchTimetable, fetchTimetable,
hireStaff, hireStaff,
@@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual, ...actual,
fetchStaffing: vi.fn(), fetchStaffing: vi.fn(),
fetchTimetable: vi.fn(), fetchTimetable: vi.fn(),
fetchDressRules: vi.fn(),
fetchPerson: vi.fn(), fetchPerson: vi.fn(),
fetchGameStatus: vi.fn().mockResolvedValue({ fetchGameStatus: vi.fn().mockResolvedValue({
tick: 0, tick: 0,
@@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => {
setLocale('en'); setLocale('en');
vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchStaffing).mockReset();
vi.mocked(fetchTimetable).mockReset(); vi.mocked(fetchTimetable).mockReset();
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue({ vi.mocked(fetchStaffing).mockResolvedValue({
...staffing(), ...staffing(),
applicants: [], applicants: [],
@@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => {
], ],
}); });
vi.mocked(fetchTimetable).mockResolvedValue(timetable()); vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchDressRules).mockResolvedValue({
students: { form: 'regular', color: 'noBright' },
staff: { form: 'regular', color: 'noBright' },
pendingStudents: null,
pendingStaff: null,
});
}); });
afterEach(() => { 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();
});
});
+57 -3
View File
@@ -12,6 +12,7 @@ import {
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { ApplicantsDialog } from './applicantsDialog.ts'; import { ApplicantsDialog } from './applicantsDialog.ts';
import { DressRulesPanel } from './dressRulesPanel.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { formatPersonPlace } from './personCard.ts'; import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts'; import { PersonCardHost } from './personCardHost.ts';
@@ -28,6 +29,10 @@ export class ManagementPanel {
readonly listElement: HTMLElement; readonly listElement: HTMLElement;
readonly cardElement: 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 error = el('p', { class: 'staffing__error' });
private readonly money = el('dl', { class: 'staffing__money' }); private readonly money = el('dl', { class: 'staffing__money' });
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' }); private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
@@ -65,13 +70,14 @@ export class ManagementPanel {
private locate: ((id: string) => string) | null = null; private locate: ((id: string) => string) | null = null;
private poolDialog: ApplicantsDialog | null = null; private poolDialog: ApplicantsDialog | null = null;
private readonly cardHost = new PersonCardHost(); private readonly cardHost = new PersonCardHost();
private manageTab: 'staff' | 'rules' = 'staff';
constructor() { constructor() {
this.error.hidden = true; this.error.hidden = true;
this.applicantsButton.addEventListener('click', () => void this.openApplicants()); this.applicantsButton.addEventListener('click', () => void this.openApplicants());
this.listElement = el( this.staffTab.addEventListener('click', () => this.showManageTab('staff'));
'div', this.rulesTab.addEventListener('click', () => this.showManageTab('rules'));
{ class: 'panel__body staffing' }, this.staffPane.append(
this.error, this.error,
this.money, this.money,
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered), el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
@@ -96,6 +102,13 @@ export class ManagementPanel {
this.timetableGrid.element, 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.cardElement = this.card;
this.classSelect.addEventListener('change', () => { this.classSelect.addEventListener('change', () => {
this.classId = this.classSelect.value || null; this.classId = this.classSelect.value || null;
@@ -105,6 +118,8 @@ export class ManagementPanel {
} }
localize(): void { localize(): void {
this.staffTab.textContent = t('manageTabStaff');
this.rulesTab.textContent = t('manageTabRules');
this.uncoveredTitle.textContent = t('staffUncovered'); this.uncoveredTitle.textContent = t('staffUncovered');
this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsTitle.textContent = t('staffApplicants');
this.applicantsButton.textContent = t('staffApplicantsOpen'); this.applicantsButton.textContent = t('staffApplicantsOpen');
@@ -116,6 +131,8 @@ export class ManagementPanel {
this.timetableGrid.localize(); this.timetableGrid.localize();
this.personGrid.localize(); this.personGrid.localize();
this.personTimetableTitle.textContent = t('timetableTitle'); this.personTimetableTitle.textContent = t('timetableTitle');
this.rulesPanel.localize();
this.paintManageTabs();
this.paint(); this.paint();
this.poolDialog?.localize(); this.poolDialog?.localize();
if (this.selectedId !== null) { if (this.selectedId !== null) {
@@ -133,12 +150,43 @@ export class ManagementPanel {
this.staffing = null; this.staffing = null;
this.timetable = null; this.timetable = null;
this.classId = null; this.classId = null;
this.manageTab = 'staff';
this.clearError(); this.clearError();
} }
this.paintManageTabs();
this.timetableGrid.attach(schoolId); this.timetableGrid.attach(schoolId);
this.cardHost.attach(schoolId); this.cardHost.attach(schoolId);
void this.reload(); 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 { setLocate(locate: (id: string) => string): void {
@@ -402,6 +450,12 @@ export class ManagementPanel {
} }
private paintCard(card: PersonCard | null): void { 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) { if (card === null) {
clear(this.card); clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') })); this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
+1
View File
@@ -489,6 +489,7 @@ function portraitPromptView(
{ class: 'people__portrait-prompt-wrap' }, { class: 'people__portrait-prompt-wrap' },
toggle, toggle,
el('div', { class: 'people__portrait-prompt-view' }, 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('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }), el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }), 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);
}
+15
View File
@@ -112,6 +112,21 @@ public static class Appropriateness
return false; 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; return true;
} }
+3 -1
View File
@@ -295,7 +295,9 @@ internal static class SchoolEndpoints
PortraitKindParser.ToApiValue(result.Kind), PortraitKindParser.ToApiValue(result.Kind),
result.Positive, result.Positive,
result.Negative, result.Negative,
result.PromptExtra)), result.PromptExtra,
result.PresetId,
result.PresetLabel)),
PortraitPromptBuildOutcome.InvalidPrompt => PortraitPromptBuildOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."), Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitPromptBuildOutcome.UnknownPerson => 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( public static (string Positive, string Negative) Build(
PersonCardResponse card, PersonCardResponse card,
SwarmUiSettings settings, SwarmUiResolvedProfile profile,
PortraitKind kind, PortraitKind kind,
string? promptExtra = null) string? promptExtra = null)
{ {
var preset = settings.PresetFor(kind); var kindPreset = profile.KindPreset;
var parts = new List<string>(); 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) if (kind == PortraitKind.Custom)
@@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder
parts.Add(promptExtra.Trim()); 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}"); parts.Add($"age {card.Age}");
foreach (var row in card.Body) 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 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); 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";
}
} }
+22 -12
View File
@@ -5,7 +5,7 @@ namespace HSchool.Server.Game;
internal sealed class PortraitService( internal sealed class PortraitService(
SchoolStore store, SchoolStore store,
SwarmUiClient swarm, SwarmUiClient swarm,
SwarmUiSettings settings, SwarmUiSettingsStore settingsStore,
GameCommandQueue commands, GameCommandQueue commands,
ILogger<PortraitService> logger) ILogger<PortraitService> logger)
{ {
@@ -74,12 +74,15 @@ internal sealed class PortraitService(
return PortraitPromptBuildResult.UnknownSchool; 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( return PortraitPromptBuildResult.Succeeded(
kind, kind,
positive, positive,
negative, negative,
kind == PortraitKind.Custom ? resolved : null); kind == PortraitKind.Custom ? resolved : null,
profile.PresetId,
profile.PresetLabel);
} }
public async Task<PortraitGenerationResult> GenerateAsync( public async Task<PortraitGenerationResult> GenerateAsync(
@@ -119,11 +122,12 @@ internal sealed class PortraitService(
return PortraitGenerationResult.UnknownSchool; 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 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); store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
var flags = Flags(schoolId, personId); var flags = Flags(schoolId, personId);
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId); var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
@@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult(
PortraitKind Kind, PortraitKind Kind,
string Positive, string Positive,
string Negative, string Negative,
string? PromptExtra) string? PromptExtra,
string PresetId,
string PresetLabel)
{ {
public static PortraitPromptBuildResult UnknownSchool { get; } = 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; } = 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; } = 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( public static PortraitPromptBuildResult Succeeded(
PortraitKind kind, PortraitKind kind,
string positive, string positive,
string negative, string negative,
string? promptExtra) => string? promptExtra,
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra); string presetId,
string presetLabel) =>
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel);
} }
internal sealed record PortraitPromptResponse( internal sealed record PortraitPromptResponse(
string Kind, string Kind,
string Positive, string Positive,
string Negative, string Negative,
string? PromptExtra); string? PromptExtra,
string PresetId,
string PresetLabel);
internal enum PortraitGenerationOutcome internal enum PortraitGenerationOutcome
{ {
-1
View File
@@ -1,6 +1,5 @@
using System.Text.Json; using System.Text.Json;
using HSchool.Content; using HSchool.Content;
using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Schedule; using HSchool.Schedule;
using HSchool.Simulation; using HSchool.Simulation;
+49 -16
View File
@@ -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( public async Task<byte[]> GenerateAsync(
string prompt, string prompt,
string negativePrompt, string negativePrompt,
SwarmUiSettings settings, SwarmUiResolvedProfile profile,
PortraitKind kind,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!IsConfigured) if (!IsConfigured)
@@ -69,7 +97,7 @@ internal sealed class SwarmUiClient
return await RunWithSessionAsync(async () => return await RunWithSessionAsync(async () =>
{ {
var preset = settings.PresetFor(kind); var kind = profile.KindPreset;
var body = new Dictionary<string, object?> var body = new Dictionary<string, object?>
{ {
["session_id"] = _sessionId, ["session_id"] = _sessionId,
@@ -77,28 +105,33 @@ internal sealed class SwarmUiClient
["donotsave"] = true, ["donotsave"] = true,
["prompt"] = prompt, ["prompt"] = prompt,
["negativeprompt"] = negativePrompt, ["negativeprompt"] = negativePrompt,
["model"] = settings.Model, ["model"] = profile.Model,
["steps"] = settings.Steps, ["steps"] = profile.Steps,
["cfgscale"] = settings.CfgScale, ["cfgscale"] = profile.CfgScale,
["width"] = preset.Width, ["width"] = kind.Width,
["height"] = preset.Height, ["height"] = kind.Height,
["seed"] = settings.Seed, ["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"] = -profile.ClipSkip;
body["clipstopatlayer"] = -settings.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"); 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);
+9 -1
View File
@@ -38,7 +38,14 @@ builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddSwarmUi(builder.Configuration); builder.Services.AddSwarmUi(builder.Configuration);
builder.Services.AddSingleton<SwarmUiHealthService>(); 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.AddSingleton<PortraitService>();
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
@@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions
}); });
app.MapSchoolEndpoints(); app.MapSchoolEndpoints();
app.MapSettingsEndpoints();
app.MapTimetableEndpoints(); app.MapTimetableEndpoints();
app.MapModEndpoints(); app.MapModEndpoints();
+71 -24
View File
@@ -1,25 +1,72 @@
{ {
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", "activePresetId": "default",
"steps": 4, "presets": [
"cfgScale": 2, {
"clipSkip": 2, "id": "default",
"sampler": "dpmpp_sde", "label": "Default",
"scheduler": "karras", "model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
"seed": 3346112079, "steps": 10,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", "cfgScale": 1,
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo", "clipSkip": 2,
"avatar": { "sampler": "euler",
"width": 1024, "scheduler": "beta",
"height": 1024, "seed": 0,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible." "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",
"custom": { "positiveLoras": [],
"width": 896, "negativeLoras": [],
"height": 1152 "avatar": {
}, "width": 1024,
"fullBody": { "height": 1024,
"width": 896, "positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
"height": 1152, },
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly 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)); 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 DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff);
private sealed record DressRulePairDto(string Form, string Color); private sealed record DressRulePairDto(string Form, string Color);
@@ -86,6 +86,20 @@ public class PortraitApiTests(AppHostFixture fixture)
Assert.Equal("avatar", payload!.Kind); Assert.Equal("avatar", payload!.Kind);
Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase); Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative)); 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] [Fact]
@@ -134,7 +148,17 @@ public class PortraitApiTests(AppHostFixture fixture)
bool HasFullBody, bool HasFullBody,
string? CustomPortraitPrompt); 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); private sealed record SavesDirectoryResponse(string Path);
} }
@@ -46,6 +46,17 @@ public class AppropriatenessTests
Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"])); 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() private static DefCatalog LoadCatalog()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
@@ -8,14 +8,10 @@ public class PortraitPromptBuilderTests
[Fact] [Fact]
public void Build_IncludesAgeHairAndWornClothing() public void Build_IncludesAgeHairAndWornClothing()
{ {
var settings = new SwarmUiSettings var profile = SamplePreset().ToProfile(PortraitKind.Avatar);
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
};
var card = SampleCard(); 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("age 12", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase); Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase);
@@ -27,15 +23,9 @@ public class PortraitPromptBuilderTests
[Fact] [Fact]
public void Build_FullBody_AddsFullBodyPreset() public void Build_FullBody_AddsFullBodyPreset()
{ {
var settings = new SwarmUiSettings var preset = SamplePreset();
{ var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar);
Positive = "School photo.", var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Full), PortraitKind.Full);
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);
Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal); Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal); Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal);
@@ -45,17 +35,11 @@ public class PortraitPromptBuilderTests
[Fact] [Fact]
public void Build_Custom_UsesPromptExtraInsteadOfPreset() public void Build_Custom_UsesPromptExtraInsteadOfPreset()
{ {
var settings = new SwarmUiSettings var profile = SamplePreset().ToProfile(PortraitKind.Custom);
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
};
const string extra = "standing in a school hallway, soft window light"; const string extra = "standing in a school hallway, soft window light";
var (customPositive, _) = PortraitPromptBuilder.Build( var (customPositive, _) = PortraitPromptBuilder.Build(
SampleCard(), SampleCard(),
settings, profile,
PortraitKind.Custom, PortraitKind.Custom,
extra); extra);
@@ -65,6 +49,35 @@ public class PortraitPromptBuilderTests
Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal); 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() => private static PersonCardResponse SampleCard() =>
new( new(
"f0.c0", "f0.c0",
@@ -22,15 +22,10 @@ public class SwarmUiClientTests
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance); NullLogger<SwarmUiClient>.Instance);
var settings = new SwarmUiSettings var preset = SwarmUiPresetDefinition.CreateDefault();
{ var profile = preset.ToProfile(PortraitKind.Avatar);
Model = "model.safetensors",
Steps = 8,
CfgScale = 1,
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
};
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.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4));
Assert.Contains("/API/GetNewSession", handler.Requests[0]); Assert.Contains("/API/GetNewSession", handler.Requests[0]);
@@ -85,18 +80,15 @@ public class SwarmUiClientTests
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance); NullLogger<SwarmUiClient>.Instance);
var settings = new SwarmUiSettings var preset = SwarmUiPresetDefinition.CreateDefault();
{ preset.Steps = 4;
Model = "model.safetensors", preset.CfgScale = 2;
Steps = 4, preset.ClipSkip = 2;
CfgScale = 2, preset.Sampler = "dpmpp_sde";
ClipSkip = 2, preset.Scheduler = "karras";
Sampler = "dpmpp_sde", var profile = preset.ToProfile(PortraitKind.Avatar);
Scheduler = "karras",
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
};
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!); using var document = JsonDocument.Parse(handler.GenerateBody!);
Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32()); 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)); 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) private static int BrightWornCount(School school)
{ {
var count = 0; var count = 0;