Pause the school on warning notices until the owner dismisses them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 22:33:38 +03:00
co-authored by Cursor
parent e22d9bae33
commit f36af269bb
27 changed files with 993 additions and 51 deletions
+13 -13
View File
@@ -11,25 +11,25 @@
## Задачи
- [ ] `Notice` с `pause`: работник ставит `running=false`. `SetRunning(true)` игнор, пока есть
- [x] `Notice` с `pause`: работник ставит `running=false`. `SetRunning(true)` игнор, пока есть
хотя бы одно такое уведомление (как нелегальный skip — не fatal)
- [ ] Модал (не тост): висит, очередь если несколько, на экране один. Закрытие — dismiss
- [ ] Sticky в сейве школы; после загрузки и Open — снова кадры. Info по-прежнему не сохранять
- [ ] Dismiss pausing — только хозяин; гость кадр видит, dismiss игнор / HTTP `403` `not-owner`
- [ ] Потолок 8 sticky; девятый не эмитить. После последнего dismiss часы сами не запускать
- [ ] Ванильный `GenerationFailed` (error, pause) можно положить в каталог; эмит с портрета —
- [x] Модал (не тост): висит, очередь если несколько, на экране один. Закрытие — dismiss
- [x] Sticky в сейве школы; после загрузки и Open — снова кадры. Info по-прежнему не сохранять
- [x] Dismiss pausing — только хозяин; гость кадр видит, dismiss игнор / HTTP `403` `not-owner`
- [x] Потолок 8 sticky; девятый не эмитить. После последнего dismiss часы сами не запускать
- [x] Ванильный `GenerationFailed` (error, pause) можно положить в каталог; эмит с портрета —
фаза 67, здесь достаточно тестового/внутреннего поста на доску
- [ ] [`protocol.md`](../../protocol.md) — поведение паузы и прав, без лишнего бампа версии
- [x] [`protocol.md`](../../protocol.md) — поведение паузы и прав, без лишнего бампа версии
(версия уже +1 в 64)
## Тесты, без которых фаза не закрыта
- [ ] Пост pausing-notice: `running` становится false; последующий SetRunning(true) оставляет паузу
- [ ] Dismiss последнего pausing: SetRunning(true) снова включает часы; без Play сами не идут
- [ ] Сейв/загрузка: sticky на месте, info нет
- [ ] Гость не снимает паузу dismiss-ом; хозяин снимает
- [ ] Клиентский: модал не гаснет по TTL; очередь показывает следующий после закрытия
- [ ] Хостовый: Open после рестарта работника отдаёт sticky, часы на паузе
- [x] Пост pausing-notice: `running` становится false; последующий SetRunning(true) оставляет паузу
- [x] Dismiss последнего pausing: SetRunning(true) снова включает часы; без Play сами не идут
- [x] Сейв/загрузка: sticky на месте, info нет
- [x] Гость не снимает паузу dismiss-ом; хозяин снимает
- [x] Клиентский: модал не гаснет по TTL; очередь показывает следующий после закрытия
- [x] Хостовый: Open после рестарта работника отдаёт sticky, часы на паузе
## Критерий готовности
+25 -4
View File
@@ -797,6 +797,13 @@ rebuilds the rest around it. Same success payload as GET timetable.
Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
`404` `unknown-lesson`.
### `POST /api/schools/{id}/notices/{noticeId}/dismiss`
Owner-only close of a pausing notice. `204` even when that id is already gone. Guests get
`403` `not-owner` — the same code as a guest hire. Info is not stored, so this path is for
sticky warning/error. Socket dismiss (`0x08`) is the live intent; this HTTP route is the
same right check for clients that are not on the socket.
## Dev endpoints
These exist only when `HSchool:AllowSaveReload` is true (headless AppHost tests). They are never
@@ -848,6 +855,13 @@ read the `World`. Unknown `{id}` is `404` `unknown-school`.
`nodeId` is null when the person is off campus. `now` is the lessons occurring at `gameTime`
(empty on a break, night, weekend or holiday). `lessons` is the published table.
### `POST /api/dev/schools/{id}/notices`
Posts a catalog `EventDef` onto that school's board without going through Swarm. Body:
`{ "defName": "GenerationFailed", "personId": 0 }`. Success is `{ "id", "defName", "pause" }`.
Unknown `{id}` is `404` `unknown-school`. Unknown def or a full sticky queue is `409`
`notice-rejected`. Tests use this; portraits emit `generationFailed` in a later phase.
## WebSocket message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
@@ -925,6 +939,10 @@ Running and speed are **separate messages on purpose**. A single "set clock" mes
button to resend the other field from the client's own copy of the state, which is always at least
one tick stale — pressing play and then a speed button would pause the school again.
`SetRunning(true)` is ignored (not fatal, same as an illegal SkipEmpty) while the school has at
least one **pausing** notice. The clocks stay paused until the owner dismisses those notices **and**
presses Play — dismiss itself does not start the calendar.
Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×5`, `4 = ×10`; out-of-range values are
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (1), so ×1 is one game
minute per real second.
@@ -941,7 +959,8 @@ neighbouring field would clobber it with a stale client copy.
### `0x08` DismissNotice — 5 bytes
Closes one notice by id. Info toasts are not stored on the server; the frame still travels so a
click is one intent. Pausing dismiss (owner-only) is a later phase.
click is one intent. **Pausing** dismiss is owner-only: a guest's frame is ignored, like a guest
`SetRunning`. After the last pausing notice is gone the clocks stay paused until Play.
| Offset | Type | Field |
| --- | --- | --- |
@@ -1069,8 +1088,10 @@ on this frame; the client builds «говорит с Машей о футбол
### `0x87` Notice — variable
One school event for every connection that has that school open. Not glued to the clock frame.
Info is not written to the save and is not resent on OpenSchool. `personId` is `0` when nobody
is in frame (the generate-image button is a later phase).
Info is not written to the save and is not resent on OpenSchool. Warning/error with `pause=1`
stop the clock, stay in the save (at most eight), and are sent again on Open. The ninth sticky
notice is not emitted. `personId` is `0` when nobody is in frame (the generate-image button is a
later phase).
| Offset | Type | Field |
| --- | --- | --- |
@@ -1078,7 +1099,7 @@ is in frame (the generate-image button is a later phase).
| 1 | `u32` | notice id |
| 5 | string | `defName` |
| … | `u8` | severity: `0` info, `1` warning, `2` error |
| … | `u8` | `1` pause (later phase), `0` clocks keep running |
| … | `u8` | `1` pause (clocks stop until dismiss + Play), `0` clocks keep running |
| … | `u32` | `ttlMs`; `0` stays until dismiss |
| … | `u32` | `personId`; `0` = none |