From e0b7122f3bf7545bfa67cb6872eddfe1d19d36b6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:00:24 +0300 Subject: [PATCH 1/8] Mark phase 37 as in progress. --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index ad20e35..4521321 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -149,7 +149,7 @@ | [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура | | [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня | -| [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра | +| [37. Правила школы](37-school-rules.md) | 🔄 | Вкладка в «Управлении», четыре комбобокса, с завтра | 34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды, история — после 35; 37 стоит на 35 и 36. From 5a400be7924f75181ca33de93570a6637d0966a2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:06:45 +0300 Subject: [PATCH 2/8] 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. --- AGENTS.md | 1 + docs/design/social.md | 299 ++++++++++++ docs/phases/41-opinions.md | 42 ++ docs/phases/42-talk-circles.md | 41 ++ docs/phases/43-whisper.md | 36 ++ docs/phases/44-quarrel-fight.md | 35 ++ docs/phases/45-presence-talk.md | 34 ++ docs/phases/46-speech-home.md | 37 ++ docs/phases/47-romance-pack.md | 46 ++ docs/phases/README.md | 40 ++ docs/protocol.md | 21 +- src/HSchool.Client/src/i18n/strings.ts | 80 +++- src/HSchool.Client/src/net/api.ts | 68 +++ src/HSchool.Client/src/style.css | 37 +- src/HSchool.Client/src/ui/mainMenu.ts | 9 +- src/HSchool.Client/src/ui/personCard.ts | 1 + .../src/ui/swarmUiSettingsDialog.ts | 435 +++++++++++++++++ src/HSchool.Server/Api/SchoolEndpoints.cs | 4 +- src/HSchool.Server/Api/SettingsEndpoints.cs | 45 ++ .../Game/PortraitPromptBuilder.cs | 34 +- src/HSchool.Server/Game/PortraitService.cs | 34 +- src/HSchool.Server/Game/SwarmUiClient.cs | 65 ++- src/HSchool.Server/Game/SwarmUiDiscovery.cs | 97 ++++ .../Game/SwarmUiLoraFormatter.cs | 50 ++ src/HSchool.Server/Game/SwarmUiSettings.cs | 75 --- .../Game/SwarmUiSettingsStore.cs | 443 ++++++++++++++++++ src/HSchool.Server/Program.cs | 10 +- src/HSchool.Server/swarmui.json | 87 +++- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 24 +- .../PortraitPromptBuilderTests.cs | 59 ++- .../SwarmUiClientTests.cs | 30 +- .../SwarmUiSettingsStoreTests.cs | 43 ++ 32 files changed, 2178 insertions(+), 184 deletions(-) create mode 100644 docs/design/social.md create mode 100644 docs/phases/41-opinions.md create mode 100644 docs/phases/42-talk-circles.md create mode 100644 docs/phases/43-whisper.md create mode 100644 docs/phases/44-quarrel-fight.md create mode 100644 docs/phases/45-presence-talk.md create mode 100644 docs/phases/46-speech-home.md create mode 100644 docs/phases/47-romance-pack.md create mode 100644 src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts create mode 100644 src/HSchool.Server/Api/SettingsEndpoints.cs create mode 100644 src/HSchool.Server/Game/SwarmUiDiscovery.cs create mode 100644 src/HSchool.Server/Game/SwarmUiLoraFormatter.cs delete mode 100644 src/HSchool.Server/Game/SwarmUiSettings.cs create mode 100644 src/HSchool.Server/Game/SwarmUiSettingsStore.cs create mode 100644 tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs diff --git a/AGENTS.md b/AGENTS.md index c50acea..df500bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ way; this file is *how to work in them*. | clothing, inventory, climate, dress codes | [`docs/design/inventory.md`](docs/design/inventory.md) — slice 7, phases 29–37; country and wardrobes are live, weather is not | | login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 38–39 | | clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 | +| talks, opinions, fights, romance pack | [`docs/design/social.md`](docs/design/social.md) — slice 9, phases 41–47 | ## Commands diff --git a/docs/design/social.md b/docs/design/social.md new file mode 100644 index 0000000..99c3886 --- /dev/null +++ b/docs/design/social.md @@ -0,0 +1,299 @@ +# Общение, отношения и романтический пак + +Договорённость на срез после сессии/темпа, не текущий код. +Люди: [`people.md`](people.md). ИИ: [`ai.md`](ai.md). Одежда (уместность как образец): +[`inventory.md`](inventory.md). Моды: [`defs.md`](defs.md), [`foundation.md`](foundation.md). + +У человека появляется, **с кем** он говорит и **что** о нём думают другие. Нужда «общение» уже +есть, действие `Chat` уже есть — но это соло в коридоре. Срез делает разговор кружком, тему — +дефом, мнение — числом, вражду — ссорой и редкой дракой. Игрок это **смотрит**. Романтика не +ваниль: отдельный пак, который ещё и проверяет, что контентный мод на живой дороге работает. + +Срез отвечает на вопросы «кто кому друг», «о чём болтают» и «что меняет пак». Настроения-нужды, +приказов директора и секса нет. + +## Что меняется в уже принятом + +| Было | Стало | Почему | +| --- | --- | --- | +| `Chat` — один человек стоит в коридоре | Кружок 2–4 в одном узле, одна тема | Иначе общение не общение | +| Семья на карточке — только родство | Плюс число мнения; вкладка «Связи» | Родители по умолчанию не ноль | +| Конфликтов нет (`ai.md`) | Ссора и редкая драка без травм | Вражда должна быть видна, не только −40 в JSON | +| `example` — единственный не-core пак | Рядом контентный `romance` | Скучный образец не доказывает темы, ориентацию и патч `Chat` | +| Присутствие: id, узел, идёт/стоит | Плюс кружок (id участников + тема) | «Говорит с Машей о футболе» в локации | +| «Правила» — только одежда | Плюс политика речи | Уместность тем — тот же жест, что дресс-код | + +## Этап A — мнения и темы + +### Мнение + +У упорядоченной пары `(A → B)` одно число **−100…100**. Несимметрично: A может любить B, B — +терпеть A. Ноль не храним. Пороги и подписи — в `BehaviorDef`, не в UI: + +знакомые / приятели / друзья / близкие друзья на плюсе; натянуто / неприязнь / враги на минусе. + +Семья даёт **стартовое** смещение при генерации (числа тоже в данных): + +| Связь | Типичный старт | +| --- | --- | +| Родитель → ребёнок и обратно | сильно плюс | +| Братья и сёстры | плюс слабее | +| Партнёры в семье (уже есть на карточке) | плюс, не обязательно потолок | + +Дальше число живёт: разговоры, дом, ссоры. Родство не переписывается. + +**Дрейф.** Раз в рабочее утро (и skip ночи) каждое ненулевое мнение чуть сдвигается к своему +базису: семейная связь — к стартовому семейному числу, остальные — к нулю. Без этого дружба +первой недели вечная. Скорость — в `BehaviorDef`. Ноль снова не храним: дошли до базиса-нуля — +запись выкидываем. + +Хранение — **на человеке**, разреженно, в `people.json`. Писать при содержательном сдвиге +(разговор кончился, драка, утреннее семейное, утренний дрейф), не каждый тик. Тот же шов, что у +одежды. + +### Темы + +Новый вид `TopicDef`: id, теги уместности, возраст, роли, можно ли на уроке (шёпот), какой +язык помогает (опционально), какой навык качается каплей сверх Communication. + +Ванильные теги, не витрина: + +- учёба, игры, еда, семья, спорт, сплетни, грубое, внешность. + +Тема «внешность / форма» стыкует срез 7: исход смотрит уместность надетого (фаза 35). Не по +форме — насмешка или неловкость; по форме / ярко в свободной школе — комплимент. Нет ещё +уместности на карточке — тема живёт, модификатор ноль. + +Уместность — **оценка**, как одежда: систему саму не заставит взять грубое на линейке с +директором; взяли — мнение может упасть, учитель может оборвать. + +### Политика речи + +Вкладка «Правила», рядом с одеждой. Два комбобокса (ученики / сотрудники), не четыре как форма +и цвет: + +| Кого | Речь | +| --- | --- | +| Ученики | свободно / без грубого / только учёба | +| Сотрудники | свободно / без грубого / только учёба | + +«Применить» — **с завтра**, как дресс-код. Сегодняшние кружки доигрывают тему. + +### Вкладка карточки + +Пятая: **Связи**. Семья с числами, топ друзей и врагов, поиск по ненулевым (иначе тихая вражда +не видна). Игрок ничего не назначает. Без романтического пака колонки симпатий нет. + +## Этап B — разговоры + +### Кружок + +В одном узле собирается **2–4** человека, одна тема на всех. Пятый начинает соседний кружок, +не влезает в этот. + +Кого зовут: сначала друзья и одноклассники в узле, потом остальные. Врагов сами не зовут — +столкновение может уйти в ссору. Тихоня реже инициатор, лидер чаще. Веса — поля на `TraitDef` +и `BehaviorDef`, не фамилии в коде. + +`Chat` перестаёт быть соло: действие занимает людей кружка. Совместимость ролей как сейчас +(ученик и сотрудник могут говорить — учитель после урока, вопрос в коридоре). + +Где: перемена, двор, обед, раздевалка, коридор. **Учительская** — отдельное действие +`StaffChat` (`room: TeachersRoom`, только сотрудники): взрослые не обязаны болтать в коридоре +с детьми, чтобы восполнить общение. На уроке — только **шёпот** (тема с тегом «можно шептать»). +Шёпот восполняет общение слабее, **режет рост навыка урока** у участников. + +**Вместе.** На перемене и в обед друзья тянутся в узел, где уже стоят свои, а не в случайный +коридор. Враги не садятся в один кружок (уже) и не выбирают тот же обеденный узел, если есть +другой. Это веса цели «досуг», не новая ходьба парой и не состояние присутствия: на карте +видно кучку в одном узле. + +**Телефон.** У кого в сумке или в руках есть телефон (срез 7), на перемене может быть **парный** +чат слабее живого кружка: оба с телефоном, тема из того же каталога, общение восполняется +меньше. Без телефона действия нет. На уроке телефоном не сидят — это шёпот голосом, не экран. + +### Язык и красноречие + +Отдельного навыка «красноречие» нет: это `Communication`. Языки — как сейчас. + +Общий язык: лучший общий навык среди участников выше порога из `BehaviorDef`. Нет общего — +кружок всё равно возможен (жесты), но сдвиг мнения почти нулевой и чаще отрицательный. + +`Communication` чуть растёт от разговора (меньше, чем предмет за урок). Язык, на котором +говорили, чуть растёт, если он ещё не у потолка. + +### Шёпот + +Если учитель **в том же кабинете**, шанс оборвать — из `BehaviorDef`, черты тише/громче +сдвигают. Не каждый шёпот ловят. Учитель, поймавший, начинает разговор-выговор (тот же +механизм кружка, тема «дисциплина»). + +После урока учитель может сам взять 2–4 учеников (вопрос по предмету, похвала, выговор) — +обязанность с весом, не приказ игрока. Сдвиг мнения ученика об учителе слегка масштабируется +**педагогикой** учителя: сильный педагог после похвалы плюсует больше, слабый выговор злит +сильнее. Оценок по-прежнему нет. + +### Дом + +Родители на карту не приходят. **Одно** семейное событие на человека за рабочее утро; skip +ночи его применяет, как утреннюю одежду. Тема из семейного набора, сдвиг небольшой. +Тем же утренним проходом — дрейф всех мнений к базису. Настроения-нужды нет. + +### Черты в `core` + +Уже крутят разговор: задира, тихоня, лидер, добрый, вспыльчивый. Добавляются две: + +- **сплетник** — чаще берёт темы «сплетни», сильнее двигает мнение; +- **общительный** — чаще инициатор, выше шанс большого кружка. + +Поля на `TraitDef` (вес инициативы, сдвиг к тегам тем), не списки id в коде. + +## Этап C — ссора и драка + +Отдельные действия, не только −N к мнению. + +**Ссора** — на перемене, двое (иногда трое из распавшегося кружка), общение восполняется +криком, мнения падают сильнее обычного грубого разговора. + +**Задира** не бросает кубик по школе каждый день: на человеке память жертвы (id), пока та в +ростере. Смена цели — если жертва выпустилась, ушла или мнение уже на полу. Поля на черте, не +`if (defName == "Bully")` в коде сверх проверки поля. + +**Заступничество.** Третий в том же узле с высоким плюсом к жертве может встать в ссору на её +стороне. Мнение к задире падает, к жертве растёт; кружок ссоры становится тройкой. + +**Извиниться** — отдельное действие после ссоры или выговора, пока оба в одном узле. Возвращает +часть (не всё) потерянного мнения. Задира извиняется реже (поле черты). Без этого единственный +путь вверх — «ещё поболтать» и утренний дрейф к нулю. + +**Драка** — редко, двор или физкультура. Короткая. Травм, медкабинета и вызова родителей нет. +Мнения резко падают, оба в логе. Дежурный учитель или физрук в узле **обрывает** и делает +выговор. Никого в кабинете директора насильно нет: игрока нет в приказе, отдельной очереди +«к директору» этот срез не заводит. + +Нет сотрудника рядом — драка кончается по длительности действия, лог всё равно пишется. + +## Этап D — что видит игрок + +Игрок **наблюдает**. Не мирит, не рассаживает, не запрещает дружить. + +- **Локация** — «говорит с Машей о футболе»: кадр присутствия несёт id участников и id темы, + клиент собирает строку через справочник имён и локаль темы. Имена по-прежнему не строкой в + сокете. Раскладка присутствия меняется, **версия протокола +1**. +- **Сейчас** на карточке — то же плюс история за сегодня (уже есть лог). +- **Связи** — числа и подписи. +- **Правила** — политика речи, с завтра. + +Список людей мнения не несёт. + +## Этап E — пак `romance` + +Первый **контентный** мод. `example` не раздуваем: он образец папки. `romance` выбирается в +create, `requires: ["core"]`, патчит `Chat`/темы (романтические темы появляются только с паком). + +Без пака генератор не пишет ориентацию и романтические состояния. Живой школе мод не добавить — +как все паки сейчас. + +### Ориентация + +Поле на человеке, только если пак в школе. Значения — данные пака (гетеро / би / гомо и веса +генератора), не черта «ради галочки» и не хардкод. Пак без поля ориентации романтику не крутит. + +### Симпатия и пара + +Симпатия — односторонний ярлык поверх мнения (порог из данных пака). «Тили-тили тесто» — +подпись и лог, не свидание. + +| Кто | Симпатия | Пара / свидание | +| --- | --- | --- | +| Ученик ↔ ученик близкого возраста | да | только если **обоим 18+** | +| Ученик → учитель | да, класс может дразнить; на карточке учителя видно | нет | +| Учитель → ученик | **нет**, взаимности нет, осаживает разговором | нет | +| Сотрудник ↔ сотрудник | да | да, оба взрослые | +| Сотрудник ↔ ученик как пара | нет | нет | + +Близкий возраст учеников: **±2 года или соседняя параллель**. К учителю — исключение из этого +фильтра (иначе взрослый никогда не попадает). + +Пара: только **взаимная**, только **одна**. Разрыв — событие (разговор или мнение ниже порога): +мнение падает, симпатия может остаться. Новая пара не раньше следующего дня. Неразделённая +симпатия живёт сколько угодно. Измену как механику не делаем: вторая симпатия просто висит. + +Никакого сексуального контента, портретов «для романтики» и действий кроме разговора / ссоры / +уже существующих перемещений. Детская линия — дразнилки и ярлык симпатии. + +Пак может добавить черты вроде «влюбчивый», «ревнивый» теми же полями `TraitDef`. + +## Поток данных + +Мнения и кружок — состояние мира, считает `HSchool.Ai` (выбор темы, состав, исход), пишет +симуляция. Карточка — HTTP через мейлбокс. Присутствие — сокет. Политика речи — снимок школы, +как будущий дресс-код. + +Детерминизм: свой поток на человека, обход по id, тема из сида кружка. Тот же сид и те же +паки — те же первые дружбы на той же неделе (золотой отпечаток мнений — в фазе, которая +начинает писать ненулевые пары сверх семьи). + +## Где живёт код + +Нового проекта нет. + +| Что | Куда | +| --- | --- | +| `TopicDef`, теги, поля черт, политика речи | `HSchool.Content` + JSONC | +| Стартовые мнения семьи, ориентация при паке | `HSchool.People` | +| Кружок, шёпот, ссора, драка, дом-утро | `HSchool.Ai` + адаптер в `HSchool.Simulation` | +| Кадр присутствия | `HSchool.Protocol` + `protocol.ts` + `docs/protocol.md` | +| Вкладка, правила, лог | `HSchool.Server` + клиент | +| Пак | `mods/romance/` рядом с `core` и `example` | + +## Советы, которые стоит принять сразу + +- Не заводить нужду «настроение»: мнение + общение уже две шкалы. +- Не класть имена в кадр присутствия — только id, как с людьми сейчас. +- Не лечить драку травмой: медкабинет тогда обязан стать больницей. +- Не делать «ходить вместе» отдельным состоянием присутствия: достаточно одного узла. +- `ActionDef` по-прежнему одна комната: учительская и телефон — отдельные действия, не массив комнат. +- Не вшивать романтику в `core`: ванильная школа должна собираться без пака. +- Не расширять `example`. Контент — другой пак. +- Не делать учительскую взаимность «опцией мода»: пак проверяет запрет тестом. + +## Заведомо не сейчас + +- Настроение как нужда, приказы, вызов родителей, рассадка класса. +- Секс, поцелуи как действие, свидания вне карты, ревность-сюжет. +- Пара учителя и ученика, даже 18+. +- Травмы, дневник дисциплины как журнал оценок. +- Смена модов у живой школы. +- Общая лента событий школы — по-прежнему лог человека. + +## Зафиксировано этим разговором + +| Тема | Решение | +| --- | --- | +| Мнение | −100…100, несимметрично, подписи по порогам, семья — старт | +| Дрейф | Каждое рабочее утро к семейному базису или к нулю; skip применяет | +| Хранение | Разреженно на человеке, писать при сдвиге | +| Темы | `TopicDef` + теги включая внешность; оценка, не запрет | +| Политика речи | Ученики/сотрудники: свободно / без грубого / только учёба; с завтра | +| Кружок | Сразу 2–4; пятый — другой кружок | +| Кого зовут | Друзья и класс в узле, врагов не зовут | +| Вместе | Друзья на перемене/обеде тянутся в один узел | +| Где | Перемена, двор, обед, после урока, учительская; на уроке — шёпот | +| Телефон | Парный чат на перемене, оба с телефоном, слабее живого | +| Шёпот | Режет урок; учитель в кабинете — шанс оборвать | +| Язык | Нет общего — слабый разговор, не отказ | +| Красноречие | `Communication`; растёт от разговора | +| Педагогика | Масштабирует мнение ученика об учителе после разговора | +| Игрок | Только смотрит | +| Экран | Вкладка «Связи»; локация из id в присутствии; протокол +1 | +| Ссора / драка | Действия; жертва задиры помнится; заступник; извинение; без травм | +| Дом | Одно семейное событие на рабочее утро; skip применяет | +| Настроение | Нет | +| Черты core | + сплетник, общительный | +| Романтика | Пак `romance`, патчит темы/`Chat` | +| Ориентация | Только с паком, поле и веса в данных | +| Симпатия учеников | Близкий возраст ±2 / соседняя параллель | +| Симпатия к учителю | Можно, видно, учитель осаждает, взаимности нет | +| Пара | Оба 18+, взаимно, одна; разрыв — событие; сотрудник↔ученик нет | +| Сотрудники | Симпатия и пара между собой — да | diff --git a/docs/phases/41-opinions.md b/docs/phases/41-opinions.md new file mode 100644 index 0000000..5984441 --- /dev/null +++ b/docs/phases/41-opinions.md @@ -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` проходят + +## Стоп + +Не собирать кружки. Не бампить сокет. Не заводить романтику. diff --git a/docs/phases/42-talk-circles.md b/docs/phases/42-talk-circles.md new file mode 100644 index 0000000..bd8a081 --- /dev/null +++ b/docs/phases/42-talk-circles.md @@ -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` проходит + +## Стоп + +Не шёпот на уроке. Не драка. Не менять кадр присутствия (локация может ещё писать «Разговор»). diff --git a/docs/phases/43-whisper.md b/docs/phases/43-whisper.md new file mode 100644 index 0000000..74de51f --- /dev/null +++ b/docs/phases/43-whisper.md @@ -0,0 +1,36 @@ +# Фаза 43. Шёпот и учитель + +## Зависимости + +- [Фаза 42](42-talk-circles.md) + +## Зачем + +На уроке можно шептаться — ценой учёбы. Учитель иногда слышит. После урока учитель сам +говорит с учениками. + +## Задачи + +- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса +- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`) +- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 % +- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у + строгого разговора +- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели +- [ ] Лог: «шептались», «учитель оборвал» + +## Тесты, без которых фаза не закрыта + +- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка +- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается +- [ ] Без учителя в узле шёпот не обрывается этим правилом +- [ ] Обычный не-шёпотный `Chat` на уроке не стартует + +## Критерий готовности + +- На математике двое шепчутся, иногда учитель обрывает, в логе это видно +- `dotnet test` проходит + +## Стоп + +Не драка. Не политика речи в UI. Не протокол. diff --git a/docs/phases/44-quarrel-fight.md b/docs/phases/44-quarrel-fight.md new file mode 100644 index 0000000..2140f4c --- /dev/null +++ b/docs/phases/44-quarrel-fight.md @@ -0,0 +1,35 @@ +# Фаза 44. Ссора и драка + +## Зависимости + +- [Фаза 42](42-talk-circles.md) + +## Зачем + +Вражда — не только минус в JSON. На перемене ссорятся, на дворе или физкультуре редко дерутся. +Травм нет. + +## Задачи + +- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора +- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта +- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName +- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается +- [ ] Лог обоих; кабинет директора не заводим +- [ ] Игрок не приказывает разнять + +## Тесты, без которых фаза не закрыта + +- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей +- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется +- [ ] Выговор после обрыва есть, если в узле был сотрудник +- [ ] Skip пустого времени не оставляет «вечную драку» в состоянии + +## Критерий готовности + +- Задира с врагом на дворе: в логе ссора или драка, мнения ниже, оба живы и на ногах +- `dotnet test` проходит + +## Стоп + +Не вызов родителей. Не травмы. Не романтика. diff --git a/docs/phases/45-presence-talk.md b/docs/phases/45-presence-talk.md new file mode 100644 index 0000000..ffa40d1 --- /dev/null +++ b/docs/phases/45-presence-talk.md @@ -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` проходят + +## Стоп + +Не политика речи. Не пак. diff --git a/docs/phases/46-speech-home.md b/docs/phases/46-speech-home.md new file mode 100644 index 0000000..850142d --- /dev/null +++ b/docs/phases/46-speech-home.md @@ -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` проходят + +## Стоп + +Не романтика. Не вызов родителей в школу. diff --git a/docs/phases/47-romance-pack.md b/docs/phases/47-romance-pack.md new file mode 100644 index 0000000..703332f --- /dev/null +++ b/docs/phases/47-romance-pack.md @@ -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`. Не смена модов у живой школы. diff --git a/docs/phases/README.md b/docs/phases/README.md index 4521321..d364a4f 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -182,3 +182,43 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [40. Темп часов](40-clock-tempo.md) | ⬜ | ×1 = минута/с, кнопки ×½ ×1 ×2 ×5 ×10, страйд тяжёлых систем | + +## Срез 9. Общение и романтический пак + +Дизайн: [`../design/social.md`](../design/social.md). + +Разговор становится кружком, мнение — числом, вражда — ссорой. Романтика не ваниль: отдельный +пак. Срез можно остановить после этапа B — школа болтает, даже если драк и пака ещё нет. + +**Этап A — мнения.** Число и вкладка. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [41. Мнения и «Связи»](41-opinions.md) | ⬜ | −100…100, семья-старт, вкладка карточки | + +**Этап B — разговоры.** Кружок, язык, шёпот. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, язык, `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` не трогать. diff --git a/docs/protocol.md b/docs/protocol.md index 69b866d..c7e95ab 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -389,13 +389,32 @@ is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `ki "kind": "avatar", "positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …", "negative": "(low quality, worst quality:1.4), …", - "promptExtra": null + "promptExtra": null, + "presetId": "default", + "presetLabel": "Default" } ``` `GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured) so the client can disable generate buttons and show reachability without trying POST first. +### `GET /api/settings/swarmui` + +Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler, +LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets. +Portrait generation resolves the preset from the person's age before building the prompt. + +### `PUT /api/settings/swarmui` + +Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return +`400` `invalid-body`. Changes apply immediately to new portrait generations. + +### `GET /api/settings/swarmui/discovery` + +When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns +`{ connected, models, loras, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI +is off or unreachable, `connected` is false and the lists are empty. + ### `GET /api/schools/{id}/dress-rules` Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index dca2acd..09c0bf5 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -11,6 +11,44 @@ const ru = { schoolsTitle: 'Школы', createSchool: 'Создать школу', + settings: 'Настройки', + save: 'Сохранить', + settingsTitle: 'SwarmUI — пресеты портретов', + settingsActivePreset: 'Пресет по умолчанию', + settingsEditPreset: 'Редактируемый пресет', + settingsAddPreset: 'Добавить пресет', + settingsPresetLabel: 'Название пресета', + settingsModel: 'Модель', + settingsSteps: 'Steps', + settingsCfg: 'CFG scale', + settingsClipSkip: 'CLIP skip', + settingsSampler: 'Sampler', + settingsScheduler: 'Scheduler', + settingsSeed: 'Seed', + settingsPositive: 'Базовый positive', + settingsNegative: 'Negative', + settingsPositiveLoras: 'Positive LoRA', + settingsNegativeLoras: 'Negative LoRA', + settingsAddLora: 'Добавить LoRA', + settingsAvatarPreset: 'Аватар', + settingsCustomPreset: 'Свой промпт (размеры)', + settingsFullBodyPreset: 'В полный рост', + settingsWidth: 'Ширина', + settingsHeight: 'Высота', + settingsKindPositive: 'Positive для вида', + settingsAgeRules: 'Правила по возрасту', + settingsAgeMin: 'От', + settingsAgeMax: 'До', + settingsAgePreset: 'Пресет', + settingsAddAgeRule: 'Добавить правило', + settingsRemoveRule: 'Удалить', + settingsRefreshLists: 'Обновить списки из SwarmUI', + settingsSwarmReady: 'SwarmUI подключён — списки моделей и семплеров доступны.', + settingsSwarmOffline: 'SwarmUI недоступен — поля можно заполнить вручную.', + settingsSwarmNotConfigured: 'SwarmUI не настроен на сервере (SwarmUi:BaseUrl).', + settingsLoadFailed: 'Не удалось загрузить настройки.', + settingsSaveFailed: 'Не удалось сохранить настройки.', + settingsDiscoveryFailed: 'Не удалось получить списки из SwarmUI.', emptySchools: 'Пока ни одной школы. Создайте первую.', loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.', createDisabledTitle: 'Удалите одну из школ, чтобы создать новую', @@ -152,7 +190,6 @@ const ru = { peopleLogEvent: 'Событие', peopleLogEmpty: 'Сегодня записей нет.', - peopleTabOverview: 'Обзор', peopleTabPortrait: 'Портрет', peoplePortraitAvatar: 'Аватар', peoplePortraitFull: 'В полный рост', @@ -178,6 +215,7 @@ const ru = { peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptLoading: 'Сборка промпта…', peoplePortraitPromptFailed: 'Не удалось получить промпт.', + peoplePortraitPromptPreset: 'Пресет: {label}', modeOverview: 'Обзор', modeManage: 'Управление', @@ -277,6 +315,44 @@ const en: Messages = { schoolsTitle: 'Schools', createSchool: 'Create school', + settings: 'Settings', + save: 'Save', + settingsTitle: 'SwarmUI portrait presets', + settingsActivePreset: 'Default preset', + settingsEditPreset: 'Preset to edit', + settingsAddPreset: 'Add preset', + settingsPresetLabel: 'Preset name', + settingsModel: 'Model', + settingsSteps: 'Steps', + settingsCfg: 'CFG scale', + settingsClipSkip: 'CLIP skip', + settingsSampler: 'Sampler', + settingsScheduler: 'Scheduler', + settingsSeed: 'Seed', + settingsPositive: 'Base positive', + settingsNegative: 'Negative', + settingsPositiveLoras: 'Positive LoRA', + settingsNegativeLoras: 'Negative LoRA', + settingsAddLora: 'Add LoRA', + settingsAvatarPreset: 'Avatar', + settingsCustomPreset: 'Custom prompt (size)', + settingsFullBodyPreset: 'Full body', + settingsWidth: 'Width', + settingsHeight: 'Height', + settingsKindPositive: 'Kind positive', + settingsAgeRules: 'Age rules', + settingsAgeMin: 'From', + settingsAgeMax: 'To', + settingsAgePreset: 'Preset', + settingsAddAgeRule: 'Add rule', + settingsRemoveRule: 'Remove', + settingsRefreshLists: 'Refresh lists from SwarmUI', + settingsSwarmReady: 'SwarmUI connected — model and sampler lists are available.', + settingsSwarmOffline: 'SwarmUI unreachable — you can still edit fields manually.', + settingsSwarmNotConfigured: 'SwarmUI is not configured on the server (SwarmUi:BaseUrl).', + settingsLoadFailed: 'Could not load settings.', + settingsSaveFailed: 'Could not save settings.', + settingsDiscoveryFailed: 'Could not fetch lists from SwarmUI.', emptySchools: 'No schools yet. Create the first one.', loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.', createDisabledTitle: 'Delete a school to create a new one', @@ -418,7 +494,6 @@ const en: Messages = { peopleLogEvent: 'Event', peopleLogEmpty: 'Nothing logged today.', - peopleTabOverview: 'Overview', peopleTabPortrait: 'Portrait', peoplePortraitAvatar: 'Avatar', peoplePortraitFull: 'Full body', @@ -444,6 +519,7 @@ const en: Messages = { peoplePortraitPromptNegative: 'Negative', peoplePortraitPromptLoading: 'Building prompt…', peoplePortraitPromptFailed: 'Could not load the prompt.', + peoplePortraitPromptPreset: 'Preset: {label}', modeOverview: 'Overview', modeManage: 'Management', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index a6b8965..31fade4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise { return request('/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 { + return request('/api/settings/swarmui'); +} + +export async function saveSwarmUiSettings(settings: SwarmUiSettingsFile): Promise { + return request('/api/settings/swarmui', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); +} + +export async function fetchSwarmUiDiscovery(): Promise { + return request('/api/settings/swarmui/discovery'); +} + export type PortraitKind = 'avatar' | 'full' | 'custom'; export interface PortraitResult { @@ -430,6 +496,8 @@ export interface PortraitPrompt { readonly positive: string; readonly negative: string; readonly promptExtra: string | null; + readonly presetId: string; + readonly presetLabel: string; } export async function fetchPortraitPrompt( diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 5fd3a0d..eb6674e 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -683,10 +683,45 @@ body { overflow-x: auto; } -.people__portrait-prompt-text--muted { +.form--settings { + max-height: min(90vh, 960px); + overflow: auto; +} + +.settings-section-title { + margin: 20px 0 8px; + font-size: 15px; +} + +.settings-subtitle { + margin: 16px 0 8px; + font-size: 14px; +} + +.settings-age-rule { + align-items: end; + margin-bottom: 8px; +} + +.settings-lora-row { + align-items: end; + margin-bottom: 8px; +} + +.settings-lora-row .field { + flex: 1; +} + +.people__portrait-prompt-meta { + margin: 0 0 8px; + font-size: 13px; color: var(--text-muted, #666); } +.field--grow { + flex: 1; +} + .people__swarm-status { margin: 0 0 12px; font-size: 13px; diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 913f758..50c94e5 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { confirmDialog } from './confirmDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts'; +import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts'; import { SchoolCard } from './schoolCard.ts'; interface MainMenuOptions { @@ -35,6 +36,10 @@ export class MainMenu { class: 'button button--primary', type: 'button', }); + private readonly settingsButton = el('button', { + class: 'button', + type: 'button', + }); private readonly limitHint = el('p', { class: 'hint' }); private readonly status = el('p', { class: 'hint hint--error' }); @@ -47,6 +52,7 @@ export class MainMenu { constructor(private readonly options: MainMenuOptions) { this.createButton.addEventListener('click', () => void this.openCreateDialog()); + this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog()); this.status.hidden = true; this.root.append( @@ -54,7 +60,7 @@ export class MainMenu { 'header', { class: 'screen__header' }, this.title, - el('div', { class: 'screen__actions' }, this.createButton), + el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton), ), this.limitHint, this.status, @@ -73,6 +79,7 @@ export class MainMenu { localize(): void { this.title.textContent = t('schoolsTitle'); this.createButton.textContent = t('createSchool'); + this.settingsButton.textContent = t('settings'); this.emptyHint.textContent = t('emptySchools'); for (const card of this.cards.values()) { diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index f1d57ed..8fbbc72 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -489,6 +489,7 @@ function portraitPromptView( { class: 'people__portrait-prompt-wrap' }, toggle, el('div', { class: 'people__portrait-prompt-view' }, + el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }), el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }), el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }), el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }), diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts new file mode 100644 index 0000000..75cc64b --- /dev/null +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts @@ -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 { + const modal = new Modal(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 { + 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 { + 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 { + 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); +} diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 0626b5f..b269c1a 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -295,7 +295,9 @@ internal static class SchoolEndpoints PortraitKindParser.ToApiValue(result.Kind), result.Positive, result.Negative, - result.PromptExtra)), + result.PromptExtra, + result.PresetId, + result.PresetLabel)), PortraitPromptBuildOutcome.InvalidPrompt => Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."), PortraitPromptBuildOutcome.UnknownPerson => diff --git a/src/HSchool.Server/Api/SettingsEndpoints.cs b/src/HSchool.Server/Api/SettingsEndpoints.cs new file mode 100644 index 0000000..68b0a9e --- /dev/null +++ b/src/HSchool.Server/Api/SettingsEndpoints.cs @@ -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 { ["code"] = code }); +} diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index a87028c..bbd4b3e 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -7,16 +7,16 @@ internal static class PortraitPromptBuilder { public static (string Positive, string Negative) Build( PersonCardResponse card, - SwarmUiSettings settings, + SwarmUiResolvedProfile profile, PortraitKind kind, string? promptExtra = null) { - var preset = settings.PresetFor(kind); + var kindPreset = profile.KindPreset; var parts = new List(); - if (!string.IsNullOrWhiteSpace(settings.Positive)) + if (!string.IsNullOrWhiteSpace(profile.Positive)) { - parts.Add(settings.Positive.Trim()); + parts.Add(profile.Positive.Trim()); } if (kind == PortraitKind.Custom) @@ -26,12 +26,12 @@ internal static class PortraitPromptBuilder parts.Add(promptExtra.Trim()); } } - else if (!string.IsNullOrWhiteSpace(preset.Positive)) + else if (!string.IsNullOrWhiteSpace(kindPreset.Positive)) { - parts.Add(preset.Positive.Trim()); + parts.Add(kindPreset.Positive.Trim()); } - parts.Add(card.Female ? "A young woman" : "A young man"); + parts.Add(DescribeSubject(card)); parts.Add($"age {card.Age}"); foreach (var row in card.Body) @@ -53,7 +53,25 @@ internal static class PortraitPromptBuilder } var positive = string.Join(", ", parts.Where(part => part.Length > 0)); - var negative = settings.Negative?.Trim() ?? string.Empty; + var negative = SwarmUiLoraFormatter.AppendLoraTags( + profile.Negative.Trim(), + profile.NegativeLoras); + return (positive, negative); } + + private static string DescribeSubject(PersonCardResponse card) + { + if (card.Age <= 11) + { + return card.Female ? "A young girl" : "A young boy"; + } + + if (card.Age <= 17) + { + return card.Female ? "A teenage girl" : "A teenage boy"; + } + + return card.Female ? "A young woman" : "A young man"; + } } diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 1063fcb..f4992a9 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -5,7 +5,7 @@ namespace HSchool.Server.Game; internal sealed class PortraitService( SchoolStore store, SwarmUiClient swarm, - SwarmUiSettings settings, + SwarmUiSettingsStore settingsStore, GameCommandQueue commands, ILogger logger) { @@ -74,12 +74,15 @@ internal sealed class PortraitService( return PortraitPromptBuildResult.UnknownSchool; } - var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved); + var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved); return PortraitPromptBuildResult.Succeeded( kind, positive, negative, - kind == PortraitKind.Custom ? resolved : null); + kind == PortraitKind.Custom ? resolved : null, + profile.PresetId, + profile.PresetLabel); } public async Task GenerateAsync( @@ -119,11 +122,12 @@ internal sealed class PortraitService( return PortraitGenerationResult.UnknownSchool; } - var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, promptExtra); + var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra); try { - var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); + var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken); store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null); var flags = Flags(schoolId, personId); var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId); @@ -203,30 +207,36 @@ internal sealed record PortraitPromptBuildResult( PortraitKind Kind, string Positive, string Negative, - string? PromptExtra) + string? PromptExtra, + string PresetId, + string PresetLabel) { public static PortraitPromptBuildResult UnknownSchool { get; } = - new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult UnknownPerson { get; } = - new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult InvalidPrompt { get; } = - new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null); + new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null, "", ""); public static PortraitPromptBuildResult Succeeded( PortraitKind kind, string positive, string negative, - string? promptExtra) => - new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra); + string? promptExtra, + string presetId, + string presetLabel) => + new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra, presetId, presetLabel); } internal sealed record PortraitPromptResponse( string Kind, string Positive, string Negative, - string? PromptExtra); + string? PromptExtra, + string PresetId, + string PresetLabel); internal enum PortraitGenerationOutcome { diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index bd5708e..ed51c3a 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -55,11 +55,39 @@ internal sealed class SwarmUiClient } } + public async Task 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 GenerateAsync( string prompt, string negativePrompt, - SwarmUiSettings settings, - PortraitKind kind, + SwarmUiResolvedProfile profile, CancellationToken cancellationToken) { if (!IsConfigured) @@ -69,7 +97,7 @@ internal sealed class SwarmUiClient return await RunWithSessionAsync(async () => { - var preset = settings.PresetFor(kind); + var kind = profile.KindPreset; var body = new Dictionary { ["session_id"] = _sessionId, @@ -77,28 +105,33 @@ internal sealed class SwarmUiClient ["donotsave"] = true, ["prompt"] = prompt, ["negativeprompt"] = negativePrompt, - ["model"] = settings.Model, - ["steps"] = settings.Steps, - ["cfgscale"] = settings.CfgScale, - ["width"] = preset.Width, - ["height"] = preset.Height, - ["seed"] = settings.Seed, + ["model"] = profile.Model, + ["steps"] = profile.Steps, + ["cfgscale"] = profile.CfgScale, + ["width"] = kind.Width, + ["height"] = kind.Height, + ["seed"] = profile.Seed, }; - if (!string.IsNullOrWhiteSpace(settings.Sampler)) + if (!string.IsNullOrWhiteSpace(profile.Sampler)) { - body["sampler"] = settings.Sampler; + body["sampler"] = profile.Sampler; } - if (!string.IsNullOrWhiteSpace(settings.Scheduler)) + if (!string.IsNullOrWhiteSpace(profile.Scheduler)) { - body["scheduler"] = settings.Scheduler; + body["scheduler"] = profile.Scheduler; } - if (settings.ClipSkip > 0) + if (profile.ClipSkip > 0) { - // SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end. - body["clipstopatlayer"] = -settings.ClipSkip; + body["clipstopatlayer"] = -profile.ClipSkip; + } + + var loras = SwarmUiLoraFormatter.FormatForApi(profile.PositiveLoras); + if (loras is not null) + { + body["loras"] = loras; } using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); diff --git a/src/HSchool.Server/Game/SwarmUiDiscovery.cs b/src/HSchool.Server/Game/SwarmUiDiscovery.cs new file mode 100644 index 0000000..69a76aa --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiDiscovery.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace HSchool.Server.Game; + +internal sealed record SwarmUiDiscovery( + bool Connected, + IReadOnlyList Models, + IReadOnlyList Loras, + IReadOnlyList Samplers, + IReadOnlyList 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 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 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 ReadStringList(JsonElement list) + { + var names = new List(); + 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 names, string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return; + } + + if (!names.Contains(name, StringComparer.Ordinal)) + { + names.Add(name); + } + } +} diff --git a/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs new file mode 100644 index 0000000..5c771ad --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiLoraFormatter.cs @@ -0,0 +1,50 @@ +namespace HSchool.Server.Game; + +internal static class SwarmUiLoraFormatter +{ + /// SwarmUI comma-separated lora list: name,weight,name,weight,… + public static string? FormatForApi(IReadOnlyList loras) + { + if (loras.Count == 0) + { + return null; + } + + var parts = new List(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 loras) + { + if (loras.Count == 0) + { + return prompt; + } + + var tags = loras + .Where(lora => !string.IsNullOrWhiteSpace(lora.Name)) + .Select(lora => + $"") + .ToList(); + + if (tags.Count == 0) + { + return prompt; + } + + return string.IsNullOrWhiteSpace(prompt) + ? string.Join(' ', tags) + : $"{prompt.TrimEnd()} {string.Join(' ', tags)}"; + } +} diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs deleted file mode 100644 index 6bbebfa..0000000 --- a/src/HSchool.Server/Game/SwarmUiSettings.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json; - -namespace HSchool.Server.Game; - -/// Generation defaults and prompt templates loaded from swarmui.json next to the server. -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(json, Json) ?? new SwarmUiSettings(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path); - return new SwarmUiSettings(); - } - } -} diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs new file mode 100644 index 0000000..d825ddd --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -0,0 +1,443 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSchool.Server.Game; + +/// Loads, saves and serves SwarmUI generation presets from swarmui.json. +internal sealed class SwarmUiSettingsStore(IHostEnvironment environment, ILogger 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(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 Presets { get; set; } = []; + + public List 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(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? PositiveLoras { get; set; } + + public List? 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? 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 PositiveLoras, + IReadOnlyList NegativeLoras, + SwarmUiKindPreset KindPreset); diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 6d5ab47..f80e7cd 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -38,7 +38,14 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSwarmUi(builder.Configuration); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService(), sp.GetRequiredService().CreateLogger("SwarmUiSettings"))); +builder.Services.AddSingleton(sp => +{ + var store = new SwarmUiSettingsStore( + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger()); + store.Load(); + return store; +}); builder.Services.AddSingleton(); builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); @@ -58,6 +65,7 @@ app.UseWebSockets(new WebSocketOptions }); app.MapSchoolEndpoints(); +app.MapSettingsEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index 9a900f2..aeb1e57 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -1,25 +1,66 @@ { - "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", - "steps": 4, - "cfgScale": 2, - "clipSkip": 2, - "sampler": "dpmpp_sde", - "scheduler": "karras", - "seed": 3346112079, - "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", - "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo", - "avatar": { - "width": 1024, - "height": 1024, - "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." - }, - "custom": { - "width": 896, - "height": 1152 - }, - "fullBody": { - "width": 896, - "height": 1152, - "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." - } + "activePresetId": "default", + "presets": [ + { + "id": "default", + "label": "Default", + "model": "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", + "positiveLoras": [], + "negativeLoras": [], + "avatar": { + "width": 1024, + "height": 1024, + "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." + }, + "custom": { + "width": 896, + "height": 1152 + }, + "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" } + ] } diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 3dd7ab9..50070ea 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -86,6 +86,18 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.Equal("avatar", payload!.Kind); Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase); Assert.False(string.IsNullOrWhiteSpace(payload.Negative)); + Assert.False(string.IsNullOrWhiteSpace(payload.PresetId)); + } + + [Fact] + public async Task GetSwarmUiSettings_ReturnsPresets() + { + using var client = fixture.App.CreateHttpClient("server"); + + var settings = await client.GetFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.NotEmpty(settings!.Presets); + Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); } [Fact] @@ -134,7 +146,17 @@ public class PortraitApiTests(AppHostFixture fixture) bool HasFullBody, string? CustomPortraitPrompt); - private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra); + private sealed record PortraitPromptPayload( + string Kind, + string Positive, + string Negative, + string? PromptExtra, + string PresetId, + string PresetLabel); + + private sealed record SwarmUiSettingsPayload(string ActivePresetId, IReadOnlyList Presets); + + private sealed record SwarmUiPresetPayload(string Id, string Label); private sealed record SavesDirectoryResponse(string Path); } diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 1cbd422..3a4d5b9 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -8,14 +8,10 @@ public class PortraitPromptBuilderTests [Fact] public void Build_IncludesAgeHairAndWornClothing() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - }; + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); var card = SampleCard(); - var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar); + var (positive, _) = PortraitPromptBuilder.Build(card, profile, PortraitKind.Avatar); Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase); Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase); @@ -27,15 +23,9 @@ public class PortraitPromptBuilderTests [Fact] public void Build_FullBody_AddsFullBodyPreset() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, - }; - - var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar); - var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full); + var preset = SamplePreset(); + var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar); + var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), preset.ToProfile(PortraitKind.Full), PortraitKind.Full); Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal); Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal); @@ -45,17 +35,11 @@ public class PortraitPromptBuilderTests [Fact] public void Build_Custom_UsesPromptExtraInsteadOfPreset() { - var settings = new SwarmUiSettings - { - Positive = "School photo.", - Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, - FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, - }; - + var profile = SamplePreset().ToProfile(PortraitKind.Custom); const string extra = "standing in a school hallway, soft window light"; var (customPositive, _) = PortraitPromptBuilder.Build( SampleCard(), - settings, + profile, PortraitKind.Custom, extra); @@ -65,6 +49,35 @@ public class PortraitPromptBuilderTests Assert.DoesNotContain("Standing full body.", customPositive, StringComparison.Ordinal); } + [Fact] + public void Build_ChildAge_UsesChildSubjectDescriptor() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var child = SampleCard() with { Age = 8 }; + var (positive, _) = PortraitPromptBuilder.Build(child, profile, PortraitKind.Avatar); + + Assert.Contains("young girl", positive, StringComparison.OrdinalIgnoreCase); + } + + private static SwarmUiPresetDefinition SamplePreset() + { + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.Positive = "School photo."; + preset.Avatar = new SwarmUiKindPreset + { + Width = 512, + Height = 512, + Positive = "Head and shoulders.", + }; + preset.FullBody = new SwarmUiKindPreset + { + Width = 512, + Height = 768, + Positive = "Standing full body.", + }; + return preset; + } + private static PersonCardResponse SampleCard() => new( "f0.c0", diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index d5f357f..05cc26b 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -22,15 +22,10 @@ public class SwarmUiClientTests Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), NullLogger.Instance); - var settings = new SwarmUiSettings - { - Model = "model.safetensors", - Steps = 8, - CfgScale = 1, - Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 }, - }; + var preset = SwarmUiPresetDefinition.CreateDefault(); + var profile = preset.ToProfile(PortraitKind.Avatar); - var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + var bytes = await client.GenerateAsync("a student", "bad", profile, CancellationToken.None); Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4)); Assert.Contains("/API/GetNewSession", handler.Requests[0]); @@ -85,18 +80,15 @@ public class SwarmUiClientTests Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), NullLogger.Instance); - var settings = new SwarmUiSettings - { - Model = "model.safetensors", - Steps = 4, - CfgScale = 2, - ClipSkip = 2, - Sampler = "dpmpp_sde", - Scheduler = "karras", - Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 }, - }; + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.Steps = 4; + preset.CfgScale = 2; + preset.ClipSkip = 2; + preset.Sampler = "dpmpp_sde"; + preset.Scheduler = "karras"; + var profile = preset.ToProfile(PortraitKind.Avatar); - await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + await client.GenerateAsync("a student", "bad", profile, CancellationToken.None); using var document = JsonDocument.Parse(handler.GenerateBody!); Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32()); diff --git a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs new file mode 100644 index 0000000..463ff28 --- /dev/null +++ b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs @@ -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); + } +} From 11f48b6c99bc40aea7d09742d204e740e00a0feb Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:07:42 +0300 Subject: [PATCH 3/8] Update phase documentation with new tasks and enhancements - Added a link to phase 31 in the talk circles documentation. - Expanded task list in phase 42 to include new topics and conditions for interactions. - Updated phase 43 to reflect changes in student-teacher dynamics and opinion shifts. - Enhanced phase 44 with new rules for quarrels and fights, including the role of bystanders and apologies. - Revised phase 46 to clarify the impact of weekend events on student opinions and relationships. - Updated README to reflect the latest changes in phases 42, 43, and 44. These updates improve clarity and detail in the phase documentation, ensuring better understanding of interactions and dynamics. --- docs/phases/42-talk-circles.md | 10 +++++++++- docs/phases/43-whisper.md | 2 ++ docs/phases/44-quarrel-fight.md | 5 +++++ docs/phases/46-speech-home.md | 3 ++- docs/phases/README.md | 4 ++-- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/phases/42-talk-circles.md b/docs/phases/42-talk-circles.md index bd8a081..707c8be 100644 --- a/docs/phases/42-talk-circles.md +++ b/docs/phases/42-talk-circles.md @@ -4,6 +4,7 @@ - [Фаза 41](41-opinions.md) - [Фаза 21](21-decisions.md) +- [Фаза 31](31-dress-generator.md) ## Зачем @@ -13,9 +14,14 @@ ## Задачи - [ ] `TopicDef`: теги, возраст, роли, шёпот-на-уроке, опциональный язык -- [ ] Ванильный набор тем (учёба, игры, еда, семья, спорт, сплетни, грубое) в `core` +- [ ] Ванильный набор тем (учёба, игры, еда, семья, спорт, сплетни, грубое, внешность) в `core`. + Тема внешности учитывает уместность одежды, если сервер её уже прислал; иначе модификатор 0 - [ ] Кружок 2–4 в одном узле; пятый не влезает - [ ] Кого зовут: друзья и одноклассники в узле, врагов не зовут; тихоня/лидер — поля черты +- [ ] На перемене и в обед друзья тянутся в узел своих; враги не выбирают тот же обеденный узел, + если есть другой +- [ ] `StaffChat` в `TeachersRoom`, только сотрудники +- [ ] Парный телефонный чат на перемене: оба несут `Phone`, слабее живого кружка; на уроке нет - [ ] Нет общего языка — кружок можно, сдвиг мнения слабый и чаще вниз - [ ] `Communication` растёт от разговора меньше, чем предмет за урок; язык разговора — капля, если не у потолка @@ -27,6 +33,8 @@ - [ ] Двое свободных друзей в коридоре на перемене собирают кружок; враги — нет - [ ] Пятый в узле не входит в тот же кружок +- [ ] Сотрудники в учительской могут `StaffChat`; ученик это действие не берёт +- [ ] Двое с телефоном на перемене могут парный чат; без телефона — нет - [ ] Без общего языка мнение почти не растёт при том же `Communication` - [ ] Высокий `Communication` сильнее двигает число, чем низкий, на одной теме - [ ] Каталог без `TopicDef` не стартует школу с кружками — или падает загрузка, не молча соло-Chat diff --git a/docs/phases/43-whisper.md b/docs/phases/43-whisper.md index 74de51f..de00e9e 100644 --- a/docs/phases/43-whisper.md +++ b/docs/phases/43-whisper.md @@ -17,6 +17,7 @@ - [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у строгого разговора - [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели +- [ ] Сдвиг мнения ученика об учителе масштабируется педагогикой (поле навыка, не фамилия) - [ ] Лог: «шептались», «учитель оборвал» ## Тесты, без которых фаза не закрыта @@ -24,6 +25,7 @@ - [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка - [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается - [ ] Без учителя в узле шёпот не обрывается этим правилом +- [ ] Учитель с высокой педагогикой после похвалы сильнее плюсует мнение, чем с низкой - [ ] Обычный не-шёпотный `Chat` на уроке не стартует ## Критерий готовности diff --git a/docs/phases/44-quarrel-fight.md b/docs/phases/44-quarrel-fight.md index 2140f4c..d771f99 100644 --- a/docs/phases/44-quarrel-fight.md +++ b/docs/phases/44-quarrel-fight.md @@ -14,6 +14,9 @@ - [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора - [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта - [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName +- [ ] Задира держит id жертвы, пока та в ростере; не меняет цель каждый день +- [ ] Третий с высоким плюсом к жертве может вступить в ссору на её стороне +- [ ] Действие «извиниться» после ссоры/выговора в одном узле возвращает часть мнения - [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается - [ ] Лог обоих; кабинет директора не заводим - [ ] Игрок не приказывает разнять @@ -21,6 +24,8 @@ ## Тесты, без которых фаза не закрыта - [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей +- [ ] Задира два дня подряд целится в того же человека, если тот ещё в школе +- [ ] Извинение поднимает мнение, но не выше, чем до ссоры - [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется - [ ] Выговор после обрыва есть, если в узле был сотрудник - [ ] Skip пустого времени не оставляет «вечную драку» в состоянии diff --git a/docs/phases/46-speech-home.md b/docs/phases/46-speech-home.md index 850142d..1fa09e8 100644 --- a/docs/phases/46-speech-home.md +++ b/docs/phases/46-speech-home.md @@ -24,7 +24,8 @@ - [ ] POST «только учёба» сегодня не мешает текущему кружку «игры» - [ ] На следующее рабочее утро грубая тема у учеников не стартует -- [ ] Skip с пятницы на понедельник применяет домашнее событие; мнение с родителем сдвинуто +- [ ] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с + родителем сдвинуто, брошенная дружба ближе к нулю - [ ] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра» ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index d364a4f..e933880 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -200,7 +200,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, язык, `Communication` | +| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, учительская, телефон, вместе | | [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | 42 стоит на 41; 43 — на 42. @@ -209,7 +209,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [44. Ссора и драка](44-quarrel-fight.md) | ⬜ | Перемена / двор / физкультура, выговор, без травм | +| [44. Ссора и драка](44-quarrel-fight.md) | ⬜ | Жертва задиры, заступник, извинение, двор/физкультура | | [45. Кружок в присутствии](45-presence-talk.md) | ⬜ | Id участников и темы в кадре, протокол +1 | 44 и 45 стоят на 42, можно параллельно. From b36db2ab2b269257b1bab3a1a6de7335119d4618 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:14:56 +0300 Subject: [PATCH 4/8] Update SwarmUI configuration with new model settings and parameters - Changed the default model to 'pornmasterFlux2Klein_v4TurboFp8.safetensors' with updated steps and cfgScale. - Modified the sampler and scheduler settings for improved performance. - Cleared the positive prompt for better customization options. - Updated the child preset model name for consistency. - Reformatted ageRules for better readability. These changes enhance the flexibility and clarity of the SwarmUI settings. --- src/HSchool.Server/swarmui.json | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index aeb1e57..d11a1d9 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -4,15 +4,15 @@ { "id": "default", "label": "Default", - "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", - "steps": 4, - "cfgScale": 2, + "model": "pornmasterFlux2Klein_v4TurboFp8.safetensors", + "steps": 10, + "cfgScale": 1, "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", + "sampler": "euler", + "scheduler": "beta", + "seed": 0, + "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, ", + "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, explicit, blurry, deformed, bad anatomy, logo", "positiveLoras": [], "negativeLoras": [], "avatar": { @@ -22,7 +22,8 @@ }, "custom": { "width": 896, - "height": 1152 + "height": 1152, + "positive": "" }, "fullBody": { "width": 896, @@ -33,7 +34,7 @@ { "id": "child", "label": "Children", - "model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors", + "model": "DreamShaper_XL_-_Lightning_DPM\u002B\u002B_SDE.safetensors", "steps": 4, "cfgScale": 2, "clipSkip": 2, @@ -51,7 +52,8 @@ }, "custom": { "width": 896, - "height": 1152 + "height": 1152, + "positive": "" }, "fullBody": { "width": 896, @@ -61,6 +63,10 @@ } ], "ageRules": [ - { "minAge": 6, "maxAge": 11, "presetId": "child" } + { + "minAge": 6, + "maxAge": 11, + "presetId": "child" + } ] -} +} \ No newline at end of file From aab0c00988f12c622bb5c7bbc098fd020f9d6744 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:20:06 +0300 Subject: [PATCH 5/8] Add school dress rules tab in Management with tests. --- docs/phases/37-school-rules.md | 24 +- src/HSchool.Client/src/i18n/strings.ts | 50 ++++ src/HSchool.Client/src/net/api.ts | 27 ++ src/HSchool.Client/src/style.css | 15 ++ .../src/ui/dressRulesPanel.test.ts | 91 +++++++ src/HSchool.Client/src/ui/dressRulesPanel.ts | 239 ++++++++++++++++++ src/HSchool.Client/src/ui/dressRulesUi.ts | 57 +++++ .../src/ui/managementPanel.test.ts | 61 +++++ src/HSchool.Client/src/ui/managementPanel.ts | 60 ++++- src/HSchool.Content/Appropriateness.cs | 15 ++ .../DressRulesApiTests.cs | 32 +++ .../AppropriatenessTests.cs | 11 + .../AppropriatenessSimulationTests.cs | 57 +++++ 13 files changed, 724 insertions(+), 15 deletions(-) create mode 100644 src/HSchool.Client/src/ui/dressRulesPanel.test.ts create mode 100644 src/HSchool.Client/src/ui/dressRulesPanel.ts create mode 100644 src/HSchool.Client/src/ui/dressRulesUi.ts diff --git a/docs/phases/37-school-rules.md b/docs/phases/37-school-rules.md index 8473c0d..2223f7e 100644 --- a/docs/phases/37-school-rules.md +++ b/docs/phases/37-school-rules.md @@ -12,24 +12,24 @@ ## Задачи -- [ ] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим -- [ ] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников. +- [x] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим +- [x] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников. Значения — как в [`../design/inventory.md`](../design/inventory.md) -- [ ] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что +- [x] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что вступит завтра -- [ ] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила — +- [x] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила — `400`, не тишина -- [ ] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него -- [ ] Строки через `t(...)`, обе локали -- [ ] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом +- [x] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него +- [x] Строки через `t(...)`, обе локали +- [x] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом ## Тесты, без которых фаза не закрыта -- [ ] GET правил на новой школе — дефолт обычная / без яркого на обе роли -- [ ] POST «строгая» сегодня не меняет одежду до следующего рабочего утра -- [ ] После утра на теле учеников нет яркого, если отложили «без яркого» -- [ ] Неизвестный код политики — `400` -- [ ] Вкладка не ломает наём и сетку расписания +- [x] GET правил на новой школе — дефолт обычная / без яркого на обе роли +- [x] POST «строгая» сегодня не меняет одежду до следующего рабочего утра +- [x] После утра на теле учеников нет яркого, если отложили «без яркого» +- [x] Неизвестный код политики — `400` +- [x] Вкладка не ломает наём и сетку расписания ## Критерий готовности diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index dca2acd..6986d48 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -232,6 +232,31 @@ const ru = { staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorSubject: 'Такого предмета нет.', + manageTabStaff: 'Штат', + manageTabRules: 'Правила', + rulesCurrentTitle: 'Сегодня', + rulesPendingTitle: 'С завтра', + rulesPendingHint: 'Изменения вступают в силу на следующее рабочее утро.', + rulesStudentsRole: 'Ученики', + rulesStaffRole: 'Сотрудники', + rulesStudentsForm: 'Форма учеников', + rulesStudentsColor: 'Цвет учеников', + rulesStaffForm: 'Форма сотрудников', + rulesStaffColor: 'Цвет сотрудников', + rulesRoleSummary: '{role}: {form}, {color}', + rulesApply: 'Применить', + rulesLoadFailed: 'Не удалось загрузить правила одежды.', + rulesSaveFailed: 'Не удалось сохранить правила.', + rulesPickHint: 'Правила действуют на всю школу.', + dressFormRegular: 'обычная', + dressFormShort: 'короткая', + dressFormStrict: 'строгая', + dressColorFree: 'свободно', + dressColorNoBright: 'без яркого', + dressColorWhiteTopBlackBottom: 'белый верх, чёрный низ', + dressErrorUnknownForm: 'Такой формы нет.', + dressErrorUnknownColor: 'Такой цветовой политики нет.', + mapOccupancy: '{name} ({activity})', mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', @@ -498,6 +523,31 @@ const en: Messages = { staffErrorAssigned: 'That subject is already assigned.', staffErrorSubject: 'That subject is not in the catalog.', + manageTabStaff: 'Staff', + manageTabRules: 'Rules', + rulesCurrentTitle: 'Today', + rulesPendingTitle: 'From tomorrow', + rulesPendingHint: 'Changes take effect on the next work morning.', + rulesStudentsRole: 'Students', + rulesStaffRole: 'Staff', + rulesStudentsForm: 'Student form', + rulesStudentsColor: 'Student colours', + rulesStaffForm: 'Staff form', + rulesStaffColor: 'Staff colours', + rulesRoleSummary: '{role}: {form}, {color}', + rulesApply: 'Apply', + rulesLoadFailed: 'Could not load dress rules.', + rulesSaveFailed: 'Could not save the rules.', + rulesPickHint: 'Rules apply to the whole school.', + dressFormRegular: 'regular', + dressFormShort: 'short', + dressFormStrict: 'strict', + dressColorFree: 'free colours', + dressColorNoBright: 'no bright colours', + dressColorWhiteTopBlackBottom: 'white top, black bottom', + dressErrorUnknownForm: 'That form policy is not in the catalog.', + dressErrorUnknownColor: 'That colour policy is not in the catalog.', + mapOccupancy: '{name} ({activity})', mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index a6b8965..ec1f8eb 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -575,6 +575,33 @@ export async function unassignSubject( ); } +export interface DressRulePair { + readonly form: string; + readonly color: string; +} + +export interface DressRules { + readonly students: DressRulePair; + readonly staff: DressRulePair; + readonly pendingStudents: DressRulePair | null; + readonly pendingStaff: DressRulePair | null; +} + +export async function fetchDressRules(schoolId: number): Promise { + return request(`/api/schools/${schoolId}/dress-rules`); +} + +export async function setDressRules( + schoolId: number, + body: { readonly students?: DressRulePair; readonly staff?: DressRulePair }, +): Promise { + return request(`/api/schools/${schoolId}/dress-rules`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + export interface TimetableLesson { readonly classId: string; readonly classYear: number; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 5fd3a0d..c0a16e1 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -1012,6 +1012,21 @@ body { font-size: 13px; } +.rules__summary { + margin: 0 0 8px; + font-size: 14px; +} + +.rules__fieldset { + margin: 0 0 16px; + padding: 0; + border: 0; +} + +.rules__fieldset .people__field + .people__field { + margin-top: 8px; +} + .staffing__badge { margin-left: 8px; padding: 1px 6px; diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.test.ts b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts new file mode 100644 index 0000000..635ae45 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + fetchDressRules, + setDressRules, + type DressRules, +} from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { DressRulesPanel } from './dressRulesPanel.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDressRules: vi.fn(), + setDressRules: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +function rules(): DressRules { + return { + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }; +} + +describe('DressRulesPanel', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchDressRules).mockReset(); + vi.mocked(setDressRules).mockReset(); + vi.mocked(fetchDressRules).mockResolvedValue(rules()); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('does not call save when only a combobox changes', async () => { + const panel = new DressRulesPanel(); + document.body.append(panel.element); + panel.show(3); + await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + + const colorSelect = panel.element.querySelector('select.input'); + if (!(colorSelect instanceof HTMLSelectElement)) { + throw new Error('student colour select is missing'); + } + + colorSelect.value = 'free'; + colorSelect.dispatchEvent(new Event('change', { bubbles: true })); + expect(setDressRules).not.toHaveBeenCalled(); + }); + + it('shows an API error as text after apply', async () => { + vi.mocked(setDressRules).mockRejectedValue(new ApiError(400, 'unknown-form', 'bad form')); + const panel = new DressRulesPanel(); + document.body.append(panel.element); + panel.show(3); + await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + + const formSelect = panel.element.querySelector('fieldset select.input'); + if (!(formSelect instanceof HTMLSelectElement)) { + throw new Error('student form select is missing'); + } + + formSelect.value = 'strict'; + const apply = panel.element.querySelector('button.button'); + if (!(apply instanceof HTMLButtonElement)) { + throw new Error('apply button is missing'); + } + + apply.click(); + const banner = panel.element.querySelector('.staffing__error'); + if (!(banner instanceof HTMLElement)) { + throw new Error('error banner is missing'); + } + + await vi.waitFor(() => expect(banner.hidden).toBe(false)); + expect(banner.textContent).toBe(t('dressErrorUnknownForm')); + }); +}); diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.ts b/src/HSchool.Client/src/ui/dressRulesPanel.ts new file mode 100644 index 0000000..bd53983 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesPanel.ts @@ -0,0 +1,239 @@ +import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; +import { + dressColorLabel, + dressColorOptions, + dressFormLabel, + dressFormOptions, + dressRulesError, +} from './dressRulesUi.ts'; +import { fillSelect } from './staffingUi.ts'; + +/** + * School-wide dress code. Edits queue for the next work morning; today's rules stay visible. + */ +export class DressRulesPanel { + readonly element: HTMLElement; + + private readonly error = el('p', { class: 'staffing__error' }); + private readonly currentTitle = el('h3', { class: 'panel__section-title' }); + private readonly currentStudents = el('p', { class: 'rules__summary' }); + private readonly currentStaff = el('p', { class: 'rules__summary' }); + private readonly pendingTitle = el('h3', { class: 'panel__section-title' }); + private readonly pendingHint = el('p', { class: 'panel__meta' }); + private readonly studentsFormLabel = el('span', { class: 'people__label' }); + private readonly studentsFormSelect = el('select', { class: 'input people__input' }); + private readonly studentsColorLabel = el('span', { class: 'people__label' }); + private readonly studentsColorSelect = el('select', { class: 'input people__input' }); + private readonly staffFormLabel = el('span', { class: 'people__label' }); + private readonly staffFormSelect = el('select', { class: 'input people__input' }); + private readonly staffColorLabel = el('span', { class: 'people__label' }); + private readonly staffColorSelect = el('select', { class: 'input people__input' }); + private readonly applyButton = el('button', { class: 'button', type: 'button' }); + + private readonly studentsLegend: HTMLElement; + private readonly staffLegend: HTMLElement; + + private schoolId: number | null = null; + private rules: DressRules | null = null; + private loadToken = 0; + private busy = false; + + constructor() { + this.error.hidden = true; + this.applyButton.addEventListener('click', () => void this.apply()); + + this.element = el( + 'div', + { class: 'panel__body rules', hidden: true }, + this.error, + el('div', { class: 'panel__section' }, this.currentTitle, this.currentStudents, this.currentStaff), + el( + 'div', + { class: 'panel__section' }, + this.pendingTitle, + this.pendingHint, + el( + 'fieldset', + { class: 'rules__fieldset' }, + el('legend', { class: 'people__section-title', text: '' }), + el( + 'label', + { class: 'people__field' }, + this.studentsFormLabel, + this.studentsFormSelect, + ), + el( + 'label', + { class: 'people__field' }, + this.studentsColorLabel, + this.studentsColorSelect, + ), + ), + el( + 'fieldset', + { class: 'rules__fieldset' }, + el('legend', { class: 'people__section-title', text: '' }), + el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect), + el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect), + ), + this.applyButton, + ), + ); + + const legends = this.element.querySelectorAll('fieldset.rules__fieldset > legend'); + const studentsLegend = legends.item(0); + const staffLegend = legends.item(1); + if (!(studentsLegend instanceof HTMLElement) || !(staffLegend instanceof HTMLElement)) { + throw new Error('rules fieldset legends are missing'); + } + + this.studentsLegend = studentsLegend; + this.staffLegend = staffLegend; + this.localize(); + } + + localize(): void { + this.currentTitle.textContent = t('rulesCurrentTitle'); + this.pendingTitle.textContent = t('rulesPendingTitle'); + this.pendingHint.textContent = t('rulesPendingHint'); + this.studentsFormLabel.textContent = t('rulesStudentsForm'); + this.studentsColorLabel.textContent = t('rulesStudentsColor'); + this.staffFormLabel.textContent = t('rulesStaffForm'); + this.staffColorLabel.textContent = t('rulesStaffColor'); + this.applyButton.textContent = t('rulesApply'); + this.studentsLegend.textContent = t('rulesStudentsRole'); + this.staffLegend.textContent = t('rulesStaffRole'); + fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value); + fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value); + fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value); + fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value); + this.paint(); + } + + show(schoolId: number): void { + if (this.schoolId !== schoolId) { + this.schoolId = schoolId; + this.rules = null; + this.clearError(); + } + + void this.reload(); + } + + hide(): void { + this.element.hidden = true; + } + + reveal(): void { + this.element.hidden = false; + } + + private async reload(): Promise { + const schoolId = this.schoolId; + if (schoolId === null) { + return; + } + + const token = ++this.loadToken; + try { + const rules = await fetchDressRules(schoolId); + if (token !== this.loadToken) { + return; + } + + this.rules = rules; + this.paint(); + } catch { + if (token !== this.loadToken) { + return; + } + + this.showError(t('rulesLoadFailed')); + } + } + + private paint(): void { + const rules = this.rules; + this.applyButton.disabled = this.busy || rules === null; + + if (rules === null) { + this.currentStudents.textContent = '—'; + this.currentStaff.textContent = '—'; + return; + } + + this.currentStudents.textContent = t('rulesRoleSummary', { + role: t('rulesStudentsRole'), + form: dressFormLabel(rules.students.form), + color: dressColorLabel(rules.students.color), + }); + this.currentStaff.textContent = t('rulesRoleSummary', { + role: t('rulesStaffRole'), + form: dressFormLabel(rules.staff.form), + color: dressColorLabel(rules.staff.color), + }); + + const pendingStudents = rules.pendingStudents ?? rules.students; + const pendingStaff = rules.pendingStaff ?? rules.staff; + fillSelect(this.studentsFormSelect, dressFormOptions(), pendingStudents.form); + fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color); + fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form); + fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color); + } + + private async apply(): Promise { + const schoolId = this.schoolId; + const rules = this.rules; + if (schoolId === null || rules === null || this.busy) { + return; + } + + const students = this.pair(this.studentsFormSelect, this.studentsColorSelect); + const staff = this.pair(this.staffFormSelect, this.staffColorSelect); + const body: { students?: DressRulePair; staff?: DressRulePair } = {}; + if (!samePair(students, rules.pendingStudents ?? rules.students)) { + body.students = students; + } + + if (!samePair(staff, rules.pendingStaff ?? rules.staff)) { + body.staff = staff; + } + + if (body.students === undefined && body.staff === undefined) { + return; + } + + this.busy = true; + this.clearError(); + this.paint(); + try { + this.rules = await setDressRules(schoolId, body); + this.paint(); + } catch (error) { + this.showError(dressRulesError(error)); + } finally { + this.busy = false; + this.paint(); + } + } + + private pair(formSelect: HTMLSelectElement, colorSelect: HTMLSelectElement): DressRulePair { + return { form: formSelect.value, color: colorSelect.value }; + } + + private showError(message: string): void { + this.error.hidden = false; + this.error.textContent = message; + } + + private clearError(): void { + this.error.hidden = true; + this.error.textContent = ''; + } +} + +function samePair(left: DressRulePair, right: DressRulePair): boolean { + return left.form === right.form && left.color === right.color; +} diff --git a/src/HSchool.Client/src/ui/dressRulesUi.ts b/src/HSchool.Client/src/ui/dressRulesUi.ts new file mode 100644 index 0000000..b9fbbd2 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesUi.ts @@ -0,0 +1,57 @@ +import { ApiError } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; + +export const DRESS_FORMS = ['regular', 'short', 'strict'] as const; +export const DRESS_COLORS = ['free', 'noBright', 'whiteTopBlackBottom'] as const; + +export type DressForm = (typeof DRESS_FORMS)[number]; +export type DressColor = (typeof DRESS_COLORS)[number]; + +export function dressFormOptions(): readonly { value: DressForm; label: string }[] { + return DRESS_FORMS.map((value) => ({ value, label: dressFormLabel(value) })); +} + +export function dressColorOptions(): readonly { value: DressColor; label: string }[] { + return DRESS_COLORS.map((value) => ({ value, label: dressColorLabel(value) })); +} + +export function dressFormLabel(form: string): string { + switch (form) { + case 'regular': + return t('dressFormRegular'); + case 'short': + return t('dressFormShort'); + case 'strict': + return t('dressFormStrict'); + default: + return form; + } +} + +export function dressColorLabel(color: string): string { + switch (color) { + case 'free': + return t('dressColorFree'); + case 'noBright': + return t('dressColorNoBright'); + case 'whiteTopBlackBottom': + return t('dressColorWhiteTopBlackBottom'); + default: + return color; + } +} + +export function dressRulesError(error: unknown): string { + if (!(error instanceof ApiError)) { + return t('rulesSaveFailed'); + } + + switch (error.code) { + case 'unknown-form': + return t('dressErrorUnknownForm'); + case 'unknown-color': + return t('dressErrorUnknownColor'); + default: + return error.message.length > 0 ? error.message : t('rulesSaveFailed'); + } +} diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index b7760b2..f1aa375 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -6,6 +6,7 @@ import { ApiError, fetchPerson, fetchGameStatus, + fetchDressRules, fetchStaffing, fetchTimetable, hireStaff, @@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { ...actual, fetchStaffing: vi.fn(), fetchTimetable: vi.fn(), + fetchDressRules: vi.fn(), fetchPerson: vi.fn(), fetchGameStatus: vi.fn().mockResolvedValue({ tick: 0, @@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => { setLocale('en'); vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchDressRules).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue({ ...staffing(), applicants: [], @@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => { ], }); vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchDressRules).mockResolvedValue({ + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }); }); afterEach(() => { @@ -248,3 +257,55 @@ describe('ManagementPanel uncovered', () => { ); }); }); + +describe('ManagementPanel rules tab', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchStaffing).mockReset(); + vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchDressRules).mockReset(); + vi.mocked(fetchStaffing).mockResolvedValue(staffing()); + vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchDressRules).mockResolvedValue({ + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('keeps staffing and the timetable grid after visiting rules', async () => { + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled()); + + const rulesTab = [...panel.listElement.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('manageTabRules'), + ); + if (!(rulesTab instanceof HTMLButtonElement)) { + throw new Error('rules tab is missing'); + } + + rulesTab.click(); + expect(panel.listElement.querySelector('.staffing')).toBeNull(); + + const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('manageTabStaff'), + ); + if (!(staffTab instanceof HTMLButtonElement)) { + throw new Error('staff tab is missing'); + } + + staffTab.click(); + await vi.waitFor(() => expect(panel.listElement.querySelector('.staffing')).not.toBeNull()); + expect(panel.listElement.textContent).toContain(t('staffHired')); + expect(panel.listElement.textContent).toContain(t('timetableTitle')); + expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull(); + }); +}); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index b06a478..a60157f 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -12,6 +12,7 @@ import { import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { ApplicantsDialog } from './applicantsDialog.ts'; +import { DressRulesPanel } from './dressRulesPanel.ts'; import { clear, el } from './dom.ts'; import { formatPersonPlace } from './personCard.ts'; import { PersonCardHost } from './personCardHost.ts'; @@ -28,6 +29,10 @@ export class ManagementPanel { readonly listElement: HTMLElement; readonly cardElement: HTMLElement; + private readonly staffTab = el('button', { class: 'panel__tab', type: 'button' }); + private readonly rulesTab = el('button', { class: 'panel__tab', type: 'button' }); + private readonly staffPane = el('div', { class: 'panel__body staffing' }); + private readonly rulesPanel = new DressRulesPanel(); private readonly error = el('p', { class: 'staffing__error' }); private readonly money = el('dl', { class: 'staffing__money' }); private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' }); @@ -65,13 +70,14 @@ export class ManagementPanel { private locate: ((id: string) => string) | null = null; private poolDialog: ApplicantsDialog | null = null; private readonly cardHost = new PersonCardHost(); + private manageTab: 'staff' | 'rules' = 'staff'; constructor() { this.error.hidden = true; this.applicantsButton.addEventListener('click', () => void this.openApplicants()); - this.listElement = el( - 'div', - { class: 'panel__body staffing' }, + this.staffTab.addEventListener('click', () => this.showManageTab('staff')); + this.rulesTab.addEventListener('click', () => this.showManageTab('rules')); + this.staffPane.append( this.error, this.money, el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered), @@ -96,6 +102,13 @@ export class ManagementPanel { this.timetableGrid.element, ), ); + this.listElement = el( + 'div', + {}, + el('div', { class: 'panel__tabs' }, this.staffTab, this.rulesTab), + this.staffPane, + this.rulesPanel.element, + ); this.cardElement = this.card; this.classSelect.addEventListener('change', () => { this.classId = this.classSelect.value || null; @@ -105,6 +118,8 @@ export class ManagementPanel { } localize(): void { + this.staffTab.textContent = t('manageTabStaff'); + this.rulesTab.textContent = t('manageTabRules'); this.uncoveredTitle.textContent = t('staffUncovered'); this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsButton.textContent = t('staffApplicantsOpen'); @@ -116,6 +131,8 @@ export class ManagementPanel { this.timetableGrid.localize(); this.personGrid.localize(); this.personTimetableTitle.textContent = t('timetableTitle'); + this.rulesPanel.localize(); + this.paintManageTabs(); this.paint(); this.poolDialog?.localize(); if (this.selectedId !== null) { @@ -133,12 +150,43 @@ export class ManagementPanel { this.staffing = null; this.timetable = null; this.classId = null; + this.manageTab = 'staff'; this.clearError(); } + this.paintManageTabs(); + this.timetableGrid.attach(schoolId); this.cardHost.attach(schoolId); void this.reload(); + if (this.manageTab === 'rules') { + this.rulesPanel.show(schoolId); + } + } + + private showManageTab(tab: 'staff' | 'rules'): void { + this.manageTab = tab; + this.paintManageTabs(); + const schoolId = this.schoolId; + if (tab === 'rules' && schoolId !== null) { + this.rulesPanel.show(schoolId); + this.paintCard(null); + } else if (this.selectedId !== null) { + void this.openCard(this.selectedId); + } else { + this.paintCard(null); + } + } + + private paintManageTabs(): void { + this.staffTab.classList.toggle('panel__tab--active', this.manageTab === 'staff'); + this.rulesTab.classList.toggle('panel__tab--active', this.manageTab === 'rules'); + this.staffPane.hidden = this.manageTab !== 'staff'; + if (this.manageTab === 'rules') { + this.rulesPanel.reveal(); + } else { + this.rulesPanel.hide(); + } } setLocate(locate: (id: string) => string): void { @@ -402,6 +450,12 @@ export class ManagementPanel { } private paintCard(card: PersonCard | null): void { + if (this.manageTab === 'rules') { + clear(this.card); + this.card.append(el('p', { class: 'panel__empty', text: t('rulesPickHint') })); + return; + } + if (card === null) { clear(this.card); this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') })); diff --git a/src/HSchool.Content/Appropriateness.cs b/src/HSchool.Content/Appropriateness.cs index 4d75f3a..0e28910 100644 --- a/src/HSchool.Content/Appropriateness.cs +++ b/src/HSchool.Content/Appropriateness.cs @@ -112,6 +112,21 @@ public static class Appropriateness return false; } + if (rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal)) + { + if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal) + && !color.Equals("White", StringComparison.Ordinal)) + { + return false; + } + + if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal) + && !color.Equals("Black", StringComparison.Ordinal)) + { + return false; + } + } + return true; } diff --git a/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs index b50e9f2..8fa8e15 100644 --- a/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs +++ b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs @@ -54,6 +54,38 @@ public class DressRulesApiTests(AppHostFixture fixture) Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName)); } + [Fact] + public async Task UnknownForm_Returns400() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Плохая форма", Start, seed: 35); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/dress-rules", + new { students = new { form = "tuxedo", color = "noBright" } }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-form", await SchoolApiTests.ProblemCodeAsync(response)); + } + + [Fact] + public async Task UnknownColor_Returns400() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Плохой цвет", Start, seed: 35); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/dress-rules", + new { students = new { form = "regular", color = "neon" } }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-color", await SchoolApiTests.ProblemCodeAsync(response)); + } + private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff); private sealed record DressRulePairDto(string Form, string Color); diff --git a/tests/HSchool.Content.Tests/AppropriatenessTests.cs b/tests/HSchool.Content.Tests/AppropriatenessTests.cs index 25d417c..6671179 100644 --- a/tests/HSchool.Content.Tests/AppropriatenessTests.cs +++ b/tests/HSchool.Content.Tests/AppropriatenessTests.cs @@ -46,6 +46,17 @@ public class AppropriatenessTests Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"])); } + [Fact] + public void WhiteTopBlackBottom_RejectsWrongTopOrBottom() + { + var catalog = LoadCatalog(); + var rules = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom); + Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "Gray")); + Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "White")); + Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Blue")); + Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Black")); + } + private static DefCatalog LoadCatalog() { var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); diff --git a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs index 9ef4e98..12da2e9 100644 --- a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs +++ b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs @@ -152,6 +152,63 @@ public class AppropriatenessSimulationTests Assert.Equal(0, BrightWornCount(school)); } + [Fact] + public void PendingNoBright_AfterMorning_RemovesBrightWornColours() + { + using var school = Open(TuesdayMorning, seed: 31); + MorningDress.Apply(school); + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.Free), + }; + AdvanceMorning(school); + Assert.True(BrightWornCount(school) > 0, "Free colours should allow at least one bright item."); + + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright), + }; + AdvanceMorning(school); + Assert.Equal(0, BrightWornCount(school)); + } + + [Fact] + public void WhiteTopBlackBottom_AfterMorning_WhiteShirtsOnStudents() + { + using var school = Open(TuesdayMorning, seed: 31); + MorningDress.Apply(school); + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom), + }; + AdvanceMorning(school); + + var checkedAny = false; + foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent)) + { + foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn)) + { + if (!school.Catalog!.Things[item.Def].Layers.Any(layer => + layer.Equals(ApparelLayers.Top, StringComparison.Ordinal))) + { + continue; + } + + checkedAny = true; + Assert.Equal("White", item.Color); + } + } + + Assert.True(checkedAny, "Expected at least one pupil with a top-layer shirt."); + } + + private static void AdvanceMorning(School school) + { + var before = school.Clock.Time; + school.Clock.JumpTo(before.Date.AddDays(1).AddHours(6)); + ApparelWear.Apply(school, gameMinutes: 0, before); + } + private static int BrightWornCount(School school) { var count = 0; From bb2031317df4d12c6cf2aa038670fb8364473f86 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:20:36 +0300 Subject: [PATCH 6/8] Mark phase 37 complete. --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index ad20e35..735133c 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -149,7 +149,7 @@ | [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура | | [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня | -| [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра | +| [37. Правила школы](37-school-rules.md) | ✅ | Вкладка в «Управлении», четыре комбобокса, с завтра | 34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды, история — после 35; 37 стоит на 35 и 36. From d5547b7b02c3761406db19733a6e955a377d2e85 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:17:32 +0300 Subject: [PATCH 7/8] Review slice 7A phases 29-31: fix strings duplicate, journal --- docs/phases/reviewed.md | 52 ++++++++++++++++++++++++++ src/HSchool.Client/src/i18n/strings.ts | 6 +-- src/HSchool.Server/Game/SchoolStore.cs | 1 - 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index df8b1d0..e8aaf88 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -257,3 +257,55 @@ - `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`. Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5. - Срез 7 (одежда) в работе: не писать в журнал как закрытый. + +## Срез 7. Этап A — данные и гардероб + +- **Фазы:** 29–31 +- **Проверен на:** `73a2937`, 2026-08-20 +- **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`, + `src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`, + `src/HSchool.Server/{Game/SchoolStore.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`, + `src/HSchool.Client/src/ui/{createSchoolDialog.ts,personCard.ts}`, + `tests/HSchool.Content.Tests/{CountryDefTests,ApparelDefTests,VanillaCoreTests}.cs`, + `tests/HSchool.People.Tests/{DressGeneratorTests,GoldenRosterTests}.cs`, + `tests/HSchool.AppHost.Tests/{SchoolApiTests,InventoryApiTests,GoldenSaveTests,ServiceabilityTests}.cs`, + `src/HSchool.Client/src/ui/createSchoolDialog.test.ts` +- **Итог:** все 17 тестов из списков фаз на месте; исправлен дубликат `peopleTabOverview` в + `strings.ts` (ломал `npm run build`); одно замечание рядом про вкладки карточки + +Что подтверждено: + +- **29:** `CountryDef`/`ClimatePresetDef`; `namesets/` убран; ваниль `Russia` с двумя пресетами; + каталог отдаёт `countries`; create принимает `countryId`, `unknown-country` на чужую страну; + пресет от сида (`CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset`); `nameSetId: Slavic` + поднимается через `UpgradeOlderSave` (`GoldenSaveTests`, `legacy-no-native/`); формат без + страны не стартует (`ServiceabilityTests`); диалог — страны и языки (`createSchoolDialog.test.ts`); + золотой ростер обновлён (`GoldenRosterTests`). +- **30:** поля `ThingDef`/`ColorDef`, слои как роли, мебель без `layers`; ванильная палитра и + одежда в `VanillaCoreTests`; локали ru/en (`EveryVanillaLabel_ExistsInBothLocales`); учебник + один def без `subject` на def. +- **31:** `InventoryItem` с `def`/`color`/`condition`/`location`/`subject`; `DressGenerator` + детерминирован (`DressGeneratorTests`, `InventoryApiTests.SameSeed_YieldsTheSameShirt`); слои + без пересечения, платье на два слоя; пол/яркость/учебник/родители/соискатели/масса/Hauling; + карточка отдаёт worn+carried, список — нет (`InventoryApiTests`); старый `people.json` без + items одевается на reload (`PeopleJsonWithoutInventory_IsDressedOnReloadAndRewritten`); + `HSchool.People`/`HSchool.Content` без ASP.NET; протокол не тронут. + +Дописано: + +- (нет новых тестов — списки фаз закрыты существующими) + +Исправлено: + +- Дубликат ключа `peopleTabOverview` в `strings.ts` (ru/en): vitest не ловит TS2300/TS1117, + `npm run build` падал; ключ перенесён, `peopleTabPortrait` рядом с остальными вкладками. +- Повтор `using HSchool.Content` в `SchoolStore.cs` (CS0105). + +Замечено рядом (фазы 32–37, не правилось): + +- Карточка уже с вкладками (`personCard.ts`, фаза 31 обещала один свиток — позже по + `design/inventory.md`); портрет/SwarmUI-строки в `strings.ts` — не этап A. +- `ResolveClimatePreset` в `SchoolWorker` при reload без сохранённого пресета берёт первый + пресет страны (`schoolSeed: 0`, `rollIfOmitted: false`) — по дизайну для legacy; create + кидает пресет из `GameLoopService` до spawn. +- Погода, тепло, уместность, журнал дня — срез 7B–C у соседнего ревьюера. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index dca2acd..1f3042d 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -140,6 +140,7 @@ const ru = { peopleTabApparel: 'Одежда', peopleTabCarry: 'Ноша', peopleTabNow: 'Сейчас', + peopleTabPortrait: 'Портрет', peopleAtHome: 'дома', peopleApparelFit: 'Уместность: {value}', peopleLockerYes: 'Есть шкафчик', @@ -152,8 +153,6 @@ const ru = { peopleLogEvent: 'Событие', peopleLogEmpty: 'Сегодня записей нет.', - peopleTabOverview: 'Обзор', - peopleTabPortrait: 'Портрет', peoplePortraitAvatar: 'Аватар', peoplePortraitFull: 'В полный рост', peoplePortraitCustom: 'Свой промпт', @@ -406,6 +405,7 @@ const en: Messages = { peopleTabApparel: 'Clothes', peopleTabCarry: 'Carried', peopleTabNow: 'Now', + peopleTabPortrait: 'Portrait', peopleAtHome: 'home', peopleApparelFit: 'Fit: {value}', peopleLockerYes: 'Has a locker', @@ -418,8 +418,6 @@ const en: Messages = { peopleLogEvent: 'Event', peopleLogEmpty: 'Nothing logged today.', - peopleTabOverview: 'Overview', - peopleTabPortrait: 'Portrait', peoplePortraitAvatar: 'Avatar', peoplePortraitFull: 'Full body', peoplePortraitCustom: 'Custom prompt', diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index db80d5f..3c36cd0 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -1,6 +1,5 @@ using System.Text.Json; using HSchool.Content; -using HSchool.Content; using HSchool.People; using HSchool.Schedule; using HSchool.Simulation; From beb882123d98972e951e19cd2de133cdee19a7da Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 06:21:43 +0300 Subject: [PATCH 8/8] Journal hash for slice 7A review --- docs/phases/reviewed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index e8aaf88..6329d92 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -261,7 +261,7 @@ ## Срез 7. Этап A — данные и гардероб - **Фазы:** 29–31 -- **Проверен на:** `73a2937`, 2026-08-20 +- **Проверен на:** `d5547b7`, 2026-08-20 - **Пути:** `src/HSchool.Content/{CountryDef,ApparelDefs,CatalogLoader}.cs`, `src/HSchool.People/{DressGenerator,CountryClimate,RosterJson}.cs`, `src/HSchool.Server/{Game/SchoolStore.cs,Api/{ModEndpoints,SchoolEndpoints}.cs,Api/PeopleModels.cs}`,