From 9225327d316f9fa5e1c73aedfe6a4099bd6c908b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 05:02:44 +0300 Subject: [PATCH] Enhance SwarmUI settings by adding a scheduler property and updating the client to send clip stop at layer instead of clip skip. Added tests to verify the new behavior. --- AGENTS.md | 2 + docs/design/session.md | 249 ++++++++++++++++++ docs/phases/38-session.md | 46 ++++ docs/phases/39-school-owners.md | 48 ++++ docs/phases/40-clock-tempo.md | 45 ++++ docs/phases/README.md | 29 ++ src/HSchool.Server/Game/SwarmUiClient.cs | 8 +- src/HSchool.Server/Game/SwarmUiSettings.cs | 2 + src/HSchool.Server/swarmui.json | 3 +- .../SwarmUiClientTests.cs | 57 ++++ 10 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 docs/design/session.md create mode 100644 docs/phases/38-session.md create mode 100644 docs/phases/39-school-owners.md create mode 100644 docs/phases/40-clock-tempo.md diff --git a/AGENTS.md b/AGENTS.md index 600893d..c50acea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ way; this file is *how to work in them*. | what runs locally | `src/HSchool.AppHost/AppHost.cs` | | screens, dialogs, formatting, UI language | `src/HSchool.Client/src` | | 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 | ## Commands diff --git a/docs/design/session.md b/docs/design/session.md new file mode 100644 index 0000000..823c17b --- /dev/null +++ b/docs/design/session.md @@ -0,0 +1,249 @@ +# Сессия и темп + +Договорённость на следующий срез, не текущий код. +Оболочка: [`near-term.md`](near-term.md). Рантайм: [`runtime.md`](runtime.md). +Протокол: [`../protocol.md`](../protocol.md). + +Один процесс сервера, несколько браузеров в локальной сети. Это не аккаунты и не интернет: +общий пароль альфы пускает к двери, имя отличает игроков. Школу по-прежнему тикает её работник, +кто бы ни смотрел. Параллельно часы становятся смотрибельными: ×1 — минута за секунду, высокие +скорости не гоняют тяжёлые системы двадцать раз в секунду. + +Срез отвечает на вопросы «кто я», «чьи это школы» и «насколько быстро идёт календарь». Оценок, +приказов и облачных аккаунтов нет. + +## Что меняется в уже принятом + +| Было | Стало | Почему | +| --- | --- | --- | +| Любой, кто открыл клиент, видит и трогает все школы | Сначала пароль, потом имя; свои школы и чужие — разные списки | Иначе локальный мультиплеер — это гонка за одну кучу сейвов | +| `MaxSchools` = 6 на весь процесс | `MaxSchools` = 2 **на игрока**; отдельно `MaxSchoolsTotal` на процесс | Четверо по две школы уже не влезают в шесть воркеров | +| ×½ ×1 ×2 ×3 ×4, ×1 = 5 игровых минут/с | ×½ ×1 ×2 ×5 ×10, ×1 = 1 игровая минута/с | Пять минут в секунду нельзя смотреть; ×5/×10 нужнее, чем ×3/×4 | +| Скорость только множит минуты, тик всегда 20 Гц | Календарь по-прежнему 20 Гц; ходьба, решения и нужды на ×5/×10 реже | Дорогое — люди, не `GameClock.Advance` | + +## Этап A — кто вошёл + +### Пароль альфы + +Один пароль на сервер, в конфиге `HSchool:AlphaPassword`. Это не пароль пользователя и не хеш +в базе: кто знает строку — может назваться любым ещё не занятым в сети именем. + +Пустой пароль в конфиге — сервер не стартует (валидация опций). В репозитории для локальной +игры стоит заглушка `alpha`; хостовые тесты задают своё через окружение AppHost, как уже задают +папку сейвов. + +### Имя + +После верного пароля клиент просит имя. Нормализация та же, что у школы: обрезка, без +управляющих, 1–40 символов. Занятость **без учёта регистра**: `Leo` и `leo` — одно имя. Как +написали в первый раз — так и показывается. + +Имя **закреплено навсегда** в `saves/users.json` (рядом со школами, тот же каталог). С тем же +паролем альфы в него можно войти снова. «Занято» (`409` `name-online`) — только если сейчас +есть живой WebSocket с этим именем. Закрыл вкладку — имя снова свободно для входа, в том числе +с другой машины. Две вкладки одного браузера: второе соединение получает «занято». + +Это сознательная слабость альфы: пароль общий, имя и есть личность. Кто знает и пароль, и имя, +может войти как этот человек, пока тот офлайн. + +### Куки + +`POST /api/session` `{ "password", "userName" }` ставит **HttpOnly**-куку сессии +(`SameSite=Lax`, `Path=/`). Клиент пароль в `localStorage` не кладёт. Срок — недели, чтобы не +вводить каждый запуск; точное число — поле конфига, не константа в коде. + +Кука — подписанный идентификатор (ASP.NET Data Protection или HMAC), не «голый» пароль. +Рестарт процесса не выкидывает всех: подпись проверяется заново. Живое «онлайн» при рестарте +сбрасывается — сокеты умерли. + +`GET /api/session` — текущее имя или `401`. `DELETE /api/session` — выйти, сменить имя. +Без выхода второе имя с той же куки не взять. + +WebSocket `/ws/game` без валидной куки не получает Welcome: закрытие с политикой, не кадр. +Hello по-прежнему несёт только версию протокола и локаль — имя с куки, не с кадра. Раскладка +сокета не меняется, версия протокола не растёт. + +Клиент **сначала** сессия, **потом** сокет. Сейчас сокет открывается вместе со страницей; +после этого среза — только когда кука уже есть. Реконнект сокета несёт ту же куку сам. + +### Что закрыто без сессии + +Все `/api/schools`, каталог, моды, люди, расписание, найм — `401`. Живут без куки: + +- `GET /health` +- `POST` / `GET` / `DELETE /api/session` +- дев-ручки под `HSchool:AllowSaveReload` — **с** кукой, как остальной API + +Хостовые тесты логинятся в `ResetAsync` (или рядом): один тестовый пользователь на прогон, +иначе каждый тест упирается в `401`. + +## Этап B — чьи школы + +### Потолки + +`SimulationOptions.MaxSchools` меняет смысл: это **слоты одного игрока**, по умолчанию 2. +Старое «шесть на процесс» уезжает в `MaxSchoolsTotal` (по умолчанию 16) — сколько воркеров +вообще можно поднять. + +Create отказывает: + +- своих уже `MaxSchools` — `limit-reached` (как сейчас, но считает только школы этого имени); +- воркеров уже `MaxSchoolsTotal` — тот же код или соседний `server-full`. Два кода лучше: + игрок понимает, кончились *его* слоты или весь сервер. + +При старте процесса с диска поднимаем до `MaxSchoolsTotal` файлов, не до `MaxSchools`. Иначе +четверо игроков по две школы потеряют сейвы после рестарта. + +`GET /api/schools` и Welcome по-прежнему несут `maxSchools` — теперь это слоты игрока. Байтов +Welcome не трогаем, версию не бампим. Глобальный потолок — только в HTTP (`maxSchoolsTotal`). + +### Хозяин + +На сейве поле `owner` — нормализованное имя. Формат не бампим: поле опциональное, лишние JSON +поля и так игнорируются. Нет поля / пусто — школа **бесхозная**. + +Бесхозные и чужие живут в одном втором списке меню. Бесхозную может **удалить любой** +залогиненный (почистить альфу после обновления). Смотреть — все. Пауза, скорость, наём, +правила — никто: хозяина нет. Забрать себе бесхозную нельзя. + +Новая школа записывает хозяина в том же create, что и имя. + +### Меню + +Два блока, не два браузерных окна: + +1. **Мои** — нынешние карточки: создать, открыть, удалить. Кнопка создания гаснет по своим + слотам, не по чужим. +2. **Чужие** — карточка с именем хозяина (или «—», если бесхозная), открыть; удалить только + у бесхозной. + +Опрос раз в секунду тот же: патч карточек на месте, не пересборка сетки. + +### Гость внутри школы + +Открыть чужую можно. Сервер пускает `OpenSchool` любому с сессией. Кадры часов и присутствия +идут как хозяину: школа и так тикает без зрителей. + +Гость **не** может: + +- пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение); +- удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`; +- видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да. + +Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько +подписок на те же кадры. + +Клиент не угадывает права: `GET /api/schools/{id}` (карточка меню или мелкая сводка при +открытии) несёт `owner` и `mine`. Кнопки часов и вкладка управления — от `mine`, не от +«я залогинен». + +## Этап C — темп + +База `GameMinutesPerRealSecond = 1`. Таблица кнопок, индекс на проводе как сейчас: + +| Индекс | Подпись | Множитель | +| --- | --- | --- | +| 0 | ×½ | 0.5 | +| 1 | ×1 | 1 | +| 2 | ×2 | 2 | +| 3 | ×5 | 5 | +| 4 | ×10 | 10 | + +×1 по-прежнему стартовый индекс. ×3 и ×4 уходят: индексы 3 и 4 — это теперь ×5 и ×10. +Раскладка `SetSpeed` не меняется (байт индекса), версия протокола не растёт. Зеркало таблицы +в `ClockSpeed.cs` и `protocol.ts` правится вместе, как сейчас. + +Инвариант фиксированного шага жив: воркер по-прежнему будит мир 20 раз в секунду и двигает +календарь на каждом импульсе. Нельзя заменить это «прыжком на десять минут одним Tick» — тогда +нужды и ходьба применятся в новой точке, а не по пути. + +На ×½, ×1 и ×2 тяжёлые системы вызываются каждый импульс, как сейчас. + +На ×5 и ×10 тяжёлые системы вызываются реже, с **накопленными** игровыми минутами с прошлого +вызова: + +| Скорость | Импульс календаря | Тяжёлые системы | Накопление | +| --- | --- | --- | --- | +| ×5 | каждый (20 Гц) | каждый 4-й | 1 игровая минута | +| ×10 | каждый (20 Гц) | каждый 2-й | 1 игровая минута | + +Так на высоких скоростях квант ходьбы и нужд — одна игровая минута, а не двадцать мелких шагов +в секунду. Экран часов обновляется с 20 Гц: минуты сами крупные, кадр не прыгает через час. + +Тяжёлые (реже): + +- ходьба и присутствие (`PresenceSystem.Apply`); +- действия и очередь решений; +- декей нужд и тепла; +- рост навыка на уроке; +- износ одежды. + +Каждый импульс, даже на ×10: + +- `GameClock.Advance`; +- граница суток лога (6:00); +- годовой набор и недельное обновление пула; +- погода, когда она и так пересчитывается по дате. + +Пауза по-прежнему не двигает календарь; пропуск пустого времени — по-прежнему прыжок, не тик. +Потолок догона воркера (5 шагов, лишнее сбросить) не трогаем. + +## Поток данных + +Имя живёт на соединении после handshake, не в каждом HTTP-теле. HTTP читает куку, сокет — +ту же куку на upgrade. Супервизор знает `userName` клиента и при create/delete/командах часов +сверяет с `owner` школы. Работник по-прежнему не знает про «пользователя»: `SetRunning` либо +доходит из супервизора, либо нет. + +`users.json` пишет супервизор (или тонкий сервис рядом), не работник школы: это не мир. +Школьный `owner` пишет работник при create/persist, как остальные поля сейва. + +## Где живёт код + +Нового проекта нет. + +| Что | Куда | +| --- | --- | +| Пароль, кука, `users.json`, онлайн по имени | `HSchool.Server` | +| `owner` на сейве, потолки create | `HSchool.Server` + поле на снимке | +| HTTP сессии, 401/403, два списка меню | `HSchool.Server/Api` + клиент + `docs/protocol.md` | +| Таблица скоростей | `ClockSpeed` + `protocol.ts` (зеркало, не раскладка) | +| База минут/с | `SimulationOptions.GameMinutesPerRealSecond` | +| Страйд тяжёлых систем | `HSchool.Simulation` (`School.Tick`) | + +## Советы, которые стоит принять сразу + +- Не делать пароль на каждого. Для альфы это второй пикер и «я забыл», без выигрыша. +- Не класть пароль в куку и не слать его в Hello. +- Не бампить протокол из-за смены множителей и смысла `maxSchools`: байты те же. +- Не урезать календарь до 2 Гц на ×10 — тогда часы в UI лгут о «живом» времени. +- Не применять 5 игровых минут одним `PresenceSystem.Apply`: уже написано, чем это кончается. +- Не прятать чужие школы за «добавить в друзья». В альфе все на одном сервере — все видны. + +## Заведомо не сейчас + +- Регистрация, почта, уникальный пароль на игрока, OAuth. +- Права «редактор» / «админ», передача школы другому. +- Гость крутит часы или видит «Управление». +- Забрать бесхозную себе. +- Облако, отдельный процесс на игрока, синхронизация сейвов. +- Оценки, приказы, вызов родителей. +- Перегруз ходьбы от ноши, штраф урока отдельным правилом — рост навыка уже режется нуждой. + +## Зафиксировано этим разговором + +| Тема | Решение | +| --- | --- | +| Пароль | Один, `HSchool:AlphaPassword`; пустой — не стартовать | +| Имя | Закреплено навсегда; «занято» = живой сокет; регистр не различает | +| Куки | HttpOnly-сессия, не пароль в `localStorage` | +| Сокет | После сессии; без куки нет Welcome; имя не в кадре | +| Слоты | `MaxSchools` на игрока (2); `MaxSchoolsTotal` на процесс (16) | +| Меню | Мои / чужие; бесхозные в чужих, удалить может любой | +| Гость | Смотрит карту и людей; часов не трогает; «Управление» скрыто | +| Хозяин | Поле `owner` на сейве; нет поля — бесхозная | +| ×1 | 1 игровая минута за реальную секунду | +| Кнопки | ×½ ×1 ×2 ×5 ×10 | +| Тики | Календарь 20 Гц всегда; тяжёлые системы на ×5/×10 с шагом 1 игровая минута | +| Протокол | Раскладка не меняется | +| Игрок гостя | Наблюдает | diff --git a/docs/phases/38-session.md b/docs/phases/38-session.md new file mode 100644 index 0000000..edf6415 --- /dev/null +++ b/docs/phases/38-session.md @@ -0,0 +1,46 @@ +# Фаза 38. Сессия + +## Зависимости + +- [Фаза 1](01-manager-shell.md) +- [Фаза 2](02-school-worker.md) +- [Фаза 28](28-screen-tests.md) + +## Зачем + +В клиент не пускает без пароля альфы и имени. Имя запоминается. Сокет открывается только после +сессии. Школы пока общие — хозяин в фазе 39. + +## Задачи + +- [ ] `HSchool:AlphaPassword` в конфиге; пустая строка — процесс не стартует. В `appsettings.json` + заглушка для локальной игры; headless AppHost задаёт тестовый пароль окружением +- [ ] `POST /api/session` `{ password, userName }`: неверный пароль — `401` `bad-password`; + кривое имя — `400` `invalid-name` (те же правила, что у школы); имя с живым сокетом — + `409` `name-online`. Успех ставит HttpOnly-куку и при первом разе пишет имя в + `saves/users.json` (занятость без учёта регистра) +- [ ] `GET /api/session` — текущее имя или `401`. `DELETE /api/session` снимает куку +- [ ] Все игровые HTTP без куки — `401`. Живут без неё: `/health` и три ручки сессии +- [ ] WebSocket без куки не шлёт Welcome, закрывается. Hello по-прежнему версия + локаль +- [ ] Клиент: шаг пароля, шаг имени; при живой куке оба пропускаются. Сокет — после сессии. + Кнопка выхода на меню +- [ ] Хостовые тесты логинятся в общем месте (`ResetAsync` или рядом), не в каждом факте +- [ ] `docs/protocol.md` — HTTP сессии в том же коммите. Раскладку сокета и версию не трогать + +## Тесты, без которых фаза не закрыта + +- [ ] Без сессии `GET /api/schools` — `401`; после `POST /api/session` — `200` +- [ ] Верный пароль и новое имя пишут `users.json`; повтор с тем же именем (другой регистр) — + тот же человек, не вторая запись +- [ ] Пока сокет имени жив, второй `POST /api/session` с ним — `409` `name-online` +- [ ] Сокет без куки закрывается, Welcome не приходит +- [ ] Клиентский тест: без куки виден ввод пароля, не сетка школ + +## Критерий готовности + +- Открыть клиент, ввести пароль и имя, попасть в меню; обновить страницу — снова меню, без формы +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не делить школы на мои/чужие. Не менять `MaxSchools`. Не трогать таблицу скоростей. diff --git a/docs/phases/39-school-owners.md b/docs/phases/39-school-owners.md new file mode 100644 index 0000000..541e896 --- /dev/null +++ b/docs/phases/39-school-owners.md @@ -0,0 +1,48 @@ +# Фаза 39. Хозяин школы + +## Зависимости + +- [Фаза 38](38-session.md) +- [Фаза 13](13-management-tab.md) + +## Зачем + +У школы есть хозяин. Свои слоты — свои, чужие смотрят отдельно и не управляют. + +## Задачи + +- [ ] `SimulationOptions.MaxSchools` — слоты **игрока**, умолчание 2. + `MaxSchoolsTotal` — воркеры процесса, умолчание 16. Подъём с диска режет по Total, не по + слотам игрока +- [ ] Сейв несёт `owner`. Нет поля — бесхозная. Формат не бампить +- [ ] Create пишет текущего пользователя; отказ: свои слоты кончились (`limit-reached`) или + сервер полон (`server-full`) +- [ ] `GET /api/schools`: `maxSchools` — слоты игрока, `maxSchoolsTotal`, `schools` — свои, + `others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine` +- [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый) +- [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный. + `OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят +- [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет +- [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск). + Карта и люди остаются +- [ ] `docs/protocol.md` — списки и коды ошибок. Версию сокета не бампить + +## Тесты, без которых фаза не закрыта + +- [ ] Два пользователя: у каждого по две школы, третья своего — `limit-reached`; чужая в + `others`, не в `schools` +- [ ] Восьмая школа на сервере с `MaxSchoolsTotal` = 7 — `server-full`, даже если у игрока слот есть +- [ ] Гость: `POST` найма — `403`; `SetRunning` не меняет `running` хозяина +- [ ] Сейв без `owner` поднимается, лежит в `others`, удаляется вторым пользователем +- [ ] Клиентский тест: чужая карточка без кнопки удаления; в чужой школе нет «Управление» + +## Критерий готовности + +- Два браузера, два имени: каждый видит свою сетку и чужие отдельным блоком; в чужой школе + часы чужие не нажимаются +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не отдавать бесхозную в слот игрока. Не показывать гостю «Управление» даже чтением. +Не менять скорости. diff --git a/docs/phases/40-clock-tempo.md b/docs/phases/40-clock-tempo.md new file mode 100644 index 0000000..1bd4f6b --- /dev/null +++ b/docs/phases/40-clock-tempo.md @@ -0,0 +1,45 @@ +# Фаза 40. Темп часов + +## Зависимости + +- [Фаза 2](02-school-worker.md) +- [Фаза 20](20-needs-actions.md) + +## Зачем + +×1 можно смотреть. Высокие скорости — ×5 и ×10, без лишней работы ходьбы и нужд на каждом +импульсе календаря. + +## Задачи + +- [ ] `GameMinutesPerRealSecond` по умолчанию 1 (опции, `appsettings.json`, `GET /api/schools`) +- [ ] `ClockSpeed.Multipliers`: `0.5, 1, 2, 5, 10`. Зеркало в `protocol.ts`. Подписи кнопок: + ×½ ×1 ×2 ×5 ×10. Стартовый индекс по-прежнему 1 +- [ ] Воркер будит мир 20 раз в секунду на любой скорости. `GameClock.Advance`, граница лога + 6:00, годовой набор, пул соискателей, погода — каждый импульс +- [ ] На индексах ×5 и ×10 ходьба, действия, решения, нужды, тепло, обучение, износ — раз в + 4 и раз в 2 импульса соответственно, с накопленными минутами (квант — 1 игровая минута). + На ×½ ×1 ×2 — каждый импульс, как сейчас +- [ ] Не прыгать календарём одним жирным `Tick` вместо страйда +- [ ] Комментарий меню про «минута за 12 секунд» привести к новой базе +- [ ] `docs/protocol.md` — таблица множителей. Раскладку и версию сокета не трогать + +## Тесты, без которых фаза не закрыта + +- [ ] 20 шагов по 1/20 с на ×1 сдвигают календарь на 1 игровую минуту, не на 5 +- [ ] Индексы 3 и 4 за одну реальную секунду дают 5 и 10 игровых минут +- [ ] На ×10 за 20 импульсов ходьба/нужды вызваны 10 раз, календарь — 20; накопленный аргумент + минут на тяжёлый вызов — 1 +- [ ] Невалидный индекс 5 по-прежнему игнорируется +- [ ] Клиентский тест: пять кнопок ×½ ×1 ×2 ×5 ×10 + +## Критерий готовности + +- На ×1 секунда реального времени = минута в школе; ×10 не кладёт воркер, люди не телепортируются + на десять минут за кадр +- `dotnet test` и `npm test` / `build` проходят + +## Стоп + +Не менять `TickRate` и потолок догона воркера. Не урезать рассылку кадров часов. Не трогать +сессию и хозяина. diff --git a/docs/phases/README.md b/docs/phases/README.md index 9a9631e..44409e6 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -153,3 +153,32 @@ 34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды, история — после 35; 37 стоит на 35 и 36. + +## Срез 8. Сессия и темп + +Дизайн: [`../design/session.md`](../design/session.md). + +Несколько браузеров на одном сервере: общий пароль альфы, имя игрока, свои школы и чужие +отдельным списком. Часы становятся смотрибельными — минута за секунду, на высоких скоростях +тяжёлые системы тикают реже. Срез можно остановить после этапа B: мультиплеер жив, даже если +темп ещё старый. + +**Этап A — кто вошёл.** Пароль, имя, кука. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [38. Сессия](38-session.md) | ⬜ | Пароль альфы, имя, кука, сокет после входа | + +**Этап B — чьи школы.** Хозяин, слоты, гость только смотрит. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [39. Хозяин школы](39-school-owners.md) | ⬜ | `owner`, 2 слота на игрока, мои / чужие, гость без управления | + +39 стоит на 38. + +**Этап C — темп.** Не зависит от A/B, можно вести параллельно с 38. + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [40. Темп часов](40-clock-tempo.md) | ⬜ | ×1 = минута/с, кнопки ×½ ×1 ×2 ×5 ×10, страйд тяжёлых систем | diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index 50c05f3..d7fa54b 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -90,9 +90,15 @@ internal sealed class SwarmUiClient body["sampler"] = settings.Sampler; } + if (!string.IsNullOrWhiteSpace(settings.Scheduler)) + { + body["scheduler"] = settings.Scheduler; + } + if (settings.ClipSkip > 0) { - body["clipskip"] = settings.ClipSkip; + // SwarmUI "CLIP Stop At Layer" — clip skip N is layer -N from the end. + body["clipstopatlayer"] = -settings.ClipSkip; } using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs index 6d250c2..a990037 100644 --- a/src/HSchool.Server/Game/SwarmUiSettings.cs +++ b/src/HSchool.Server/Game/SwarmUiSettings.cs @@ -15,6 +15,8 @@ internal sealed class SwarmUiSettings public string Sampler { get; init; } = "euler"; + public string Scheduler { get; init; } = ""; + public long Seed { get; init; } = -1; public string Positive { get; init; } = ""; diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index d49fc4e..21fa31a 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -3,7 +3,8 @@ "steps": 4, "cfgScale": 2, "clipSkip": 2, - "sampler": "DPM++ SDE Karras", + "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", diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index a39c4fc..578c961 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -72,6 +72,63 @@ public class SwarmUiClientTests Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); } + [Fact] + public async Task GenerateAsync_SendsClipStopAtLayerForClipSkip() + { + var handler = new CapturingHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") }; + var client = new SwarmUiClient( + http, + 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 }, + }; + + await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + + using var document = JsonDocument.Parse(handler.GenerateBody!); + Assert.Equal(-2, document.RootElement.GetProperty("clipstopatlayer").GetInt32()); + Assert.False(document.RootElement.TryGetProperty("clipskip", out _)); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public string? GenerateBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath.Contains("GetNewSession", StringComparison.Ordinal)) + { + var session = JsonSerializer.Serialize(new { session_id = "sess-1" }); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(session, Encoding.UTF8, "application/json"), + }; + } + + if (request.RequestUri.AbsolutePath.Contains("GenerateText2Image", StringComparison.Ordinal)) + { + GenerateBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var payload = JsonSerializer.Serialize(new { images = new[] { "data:image/png;base64,iVBORw0KGgo=" } }); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } + private sealed class FakeHandler : HttpMessageHandler { public List Requests { get; } = [];