Files
h-school/docs/design/session.md
T

251 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Сессия и темп
Договорённость на следующий срез, не текущий код.
Оболочка: [`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 игровая минута |
| Протокол | Раскладка не меняется |
| Игрок гостя | Наблюдает |