Add SwarmUI settings management and portrait generation enhancements

- Introduced new API endpoints for managing SwarmUI settings, including fetching and saving presets and age rules.
- Updated the portrait generation logic to utilize the new settings structure, allowing for dynamic preset selection based on age.
- Enhanced UI components to support SwarmUI settings, including localization for new strings and improved styling for settings sections.
- Added tests to verify the functionality of new settings endpoints and portrait generation behavior.

This commit lays the groundwork for more flexible and user-friendly portrait generation options.
This commit is contained in:
Leonid Pershin
2026-08-20 06:06:45 +03:00
parent e0b7122f3b
commit 5a400be792
32 changed files with 2178 additions and 184 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+, взаимно, одна; разрыв — событие; сотрудник↔ученик нет |
| Сотрудники | Симпатия и пара между собой — да |
+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` проходят
## Стоп
Не собирать кружки. Не бампить сокет. Не заводить романтику.
+41
View File
@@ -0,0 +1,41 @@
# Фаза 42. Темы и кружки
## Зависимости
- [Фаза 41](41-opinions.md)
- [Фаза 21](21-decisions.md)
## Зачем
`Chat` перестаёт быть соло. В узле говорят 2–4 человека об одной теме. Язык и
`Communication` влияют на исход.
## Задачи
- [ ] `TopicDef`: теги, возраст, роли, шёпот-на-уроке, опциональный язык
- [ ] Ванильный набор тем (учёба, игры, еда, семья, спорт, сплетни, грубое) в `core`
- [ ] Кружок 2–4 в одном узле; пятый не влезает
- [ ] Кого зовут: друзья и одноклассники в узле, врагов не зовут; тихоня/лидер — поля черты
- [ ] Нет общего языка — кружок можно, сдвиг мнения слабый и чаще вниз
- [ ] `Communication` растёт от разговора меньше, чем предмет за урок; язык разговора — капля,
если не у потолка
- [ ] Черты **сплетник** и **общительный** в `core` с полями, не списками в коде
- [ ] Исход пишет мнения всех пар кружка и строку лога
- [ ] На уроке обычный `Chat` не стартует (шёпот — фаза 43)
## Тесты, без которых фаза не закрыта
- [ ] Двое свободных друзей в коридоре на перемене собирают кружок; враги — нет
- [ ] Пятый в узле не входит в тот же кружок
- [ ] Без общего языка мнение почти не растёт при том же `Communication`
- [ ] Высокий `Communication` сильнее двигает число, чем низкий, на одной теме
- [ ] Каталог без `TopicDef` не стартует школу с кружками — или падает загрузка, не молча соло-Chat
## Критерий готовности
- На перемене в коридоре двое «разговаривают», на карточке мнение сдвинулось, в логе тема
- `dotnet test` проходит
## Стоп
Не шёпот на уроке. Не драка. Не менять кадр присутствия (локация может ещё писать «Разговор»).
+36
View File
@@ -0,0 +1,36 @@
# Фаза 43. Шёпот и учитель
## Зависимости
- [Фаза 42](42-talk-circles.md)
## Зачем
На уроке можно шептаться — ценой учёбы. Учитель иногда слышит. После урока учитель сам
говорит с учениками.
## Задачи
- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса
- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`)
- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 %
- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у
строгого разговора
- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели
- [ ] Лог: «шептались», «учитель оборвал»
## Тесты, без которых фаза не закрыта
- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка
- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается
- [ ] Без учителя в узле шёпот не обрывается этим правилом
- [ ] Обычный не-шёпотный `Chat` на уроке не стартует
## Критерий готовности
- На математике двое шепчутся, иногда учитель обрывает, в логе это видно
- `dotnet test` проходит
## Стоп
Не драка. Не политика речи в UI. Не протокол.
+35
View File
@@ -0,0 +1,35 @@
# Фаза 44. Ссора и драка
## Зависимости
- [Фаза 42](42-talk-circles.md)
## Зачем
Вражда — не только минус в JSON. На перемене ссорятся, на дворе или физкультуре редко дерутся.
Травм нет.
## Задачи
- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора
- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта
- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName
- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается
- [ ] Лог обоих; кабинет директора не заводим
- [ ] Игрок не приказывает разнять
## Тесты, без которых фаза не закрыта
- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей
- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется
- [ ] Выговор после обрыва есть, если в узле был сотрудник
- [ ] 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` проходят
## Стоп
Не политика речи. Не пак.
+37
View File
@@ -0,0 +1,37 @@
# Фаза 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`. Не смена модов у живой школы.
+40
View File
@@ -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 человека, язык, `Communication` |
| [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` не трогать.
+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`.
+78 -2
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: 'Удалите одну из школ, чтобы создать новую',
@@ -152,7 +190,6 @@ const ru = {
peopleLogEvent: 'Событие', peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.', peopleLogEmpty: 'Сегодня записей нет.',
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет', peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар', peoplePortraitAvatar: 'Аватар',
peoplePortraitFull: 'В полный рост', peoplePortraitFull: 'В полный рост',
@@ -178,6 +215,7 @@ const ru = {
peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Сборка промпта…', peoplePortraitPromptLoading: 'Сборка промпта…',
peoplePortraitPromptFailed: 'Не удалось получить промпт.', peoplePortraitPromptFailed: 'Не удалось получить промпт.',
peoplePortraitPromptPreset: 'Пресет: {label}',
modeOverview: 'Обзор', modeOverview: 'Обзор',
modeManage: 'Управление', modeManage: 'Управление',
@@ -277,6 +315,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',
@@ -418,7 +494,6 @@ const en: Messages = {
peopleLogEvent: 'Event', peopleLogEvent: 'Event',
peopleLogEmpty: 'Nothing logged today.', peopleLogEmpty: 'Nothing logged today.',
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait', peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar', peoplePortraitAvatar: 'Avatar',
peoplePortraitFull: 'Full body', peoplePortraitFull: 'Full body',
@@ -444,6 +519,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',
+68
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(
+36 -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;
+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()) {
+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);
}
+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
{ {
+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();
+64 -23
View File
@@ -1,25 +1,66 @@
{ {
"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": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"seed": 3346112079, "steps": 4,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", "cfgScale": 2,
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo", "clipSkip": 2,
"avatar": { "sampler": "dpmpp_sde",
"width": 1024, "scheduler": "karras",
"height": 1024, "seed": 3346112079,
"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, 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",
"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
},
"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++_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
},
"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" }
]
} }
@@ -86,6 +86,18 @@ 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>(TestContext.Current.CancellationToken);
Assert.NotNull(settings);
Assert.NotEmpty(settings!.Presets);
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
} }
[Fact] [Fact]
@@ -134,7 +146,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);
} }
@@ -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);
}
}