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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 игровая минута |
|
||||
| Протокол | Раскладка не меняется |
|
||||
| Игрок гостя | Наблюдает |
|
||||
@@ -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`. Не трогать таблицу скоростей.
|
||||
@@ -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` проходят
|
||||
|
||||
## Стоп
|
||||
|
||||
Не отдавать бесхозную в слот игрока. Не показывать гостю «Управление» даже чтением.
|
||||
Не менять скорости.
|
||||
@@ -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` и потолок догона воркера. Не урезать рассылку кадров часов. Не трогать
|
||||
сессию и хозяина.
|
||||
@@ -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, страйд тяжёлых систем |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<SwarmUiClient>.Instance);
|
||||
|
||||
var settings = new SwarmUiSettings
|
||||
{
|
||||
Model = "model.safetensors",
|
||||
Steps = 4,
|
||||
CfgScale = 2,
|
||||
ClipSkip = 2,
|
||||
Sampler = "dpmpp_sde",
|
||||
Scheduler = "karras",
|
||||
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
|
||||
};
|
||||
|
||||
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<HttpResponseMessage> 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<string> Requests { get; } = [];
|
||||
|
||||
Reference in New Issue
Block a user