From f36af269bb4a98e92e5a0fce39b025828ed90c6f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 22:33:38 +0300 Subject: [PATCH] Pause the school on warning notices until the owner dismisses them. Co-authored-by: Cursor --- docs/phases/11-events/65-warning-pause.md | 26 +-- docs/protocol.md | 29 ++- src/HSchool.Client/src/i18n/strings.ts | 4 + src/HSchool.Client/src/style.css | 38 ++++ src/HSchool.Client/src/ui/gameScreen.ts | 24 +++ .../src/ui/noticeModals.test.ts | 69 +++++++ src/HSchool.Client/src/ui/noticeModals.ts | 87 ++++++++ src/HSchool.Client/src/ui/noticeToasts.ts | 6 +- src/HSchool.Protocol/Messages.cs | 4 +- src/HSchool.Server/Api/DevEndpoints.cs | 39 ++++ src/HSchool.Server/Api/SchoolEndpoints.cs | 35 ++++ src/HSchool.Server/Game/GameCommand.cs | 10 +- src/HSchool.Server/Game/GameLoopService.cs | 46 +++-- src/HSchool.Server/Game/NoticeBoard.cs | 141 +++++++++++++ src/HSchool.Server/Game/SchoolStore.cs | 21 ++ .../Game/SchoolWorker.Mailbox.cs | 82 +++++++- .../Game/SchoolWorker.Persist.cs | 1 + src/HSchool.Server/Game/SchoolWorker.Tick.cs | 16 +- src/HSchool.Server/Game/SchoolWorker.cs | 6 +- src/HSchool.Server/Game/WorkerCommand.cs | 10 + src/HSchool.Server/Net/GameSocketHandler.cs | 6 +- src/HSchool.Server/Program.cs | 1 + src/HSchool.Simulation/SimulationOptions.cs | 5 + .../HSchool.AppHost.Tests/GameSocketTests.cs | 185 ++++++++++++++++++ tests/HSchool.Content.Tests/EventDefTests.cs | 4 + .../HSchool.Server.Tests/NoticeBoardTests.cs | 93 +++++++++ .../HSchool.Server.Tests/SchoolStoreTests.cs | 56 ++++++ 27 files changed, 993 insertions(+), 51 deletions(-) create mode 100644 src/HSchool.Client/src/ui/noticeModals.test.ts create mode 100644 src/HSchool.Client/src/ui/noticeModals.ts create mode 100644 src/HSchool.Server/Game/NoticeBoard.cs create mode 100644 tests/HSchool.Server.Tests/NoticeBoardTests.cs diff --git a/docs/phases/11-events/65-warning-pause.md b/docs/phases/11-events/65-warning-pause.md index 6948e43..8cf3f2a 100644 --- a/docs/phases/11-events/65-warning-pause.md +++ b/docs/phases/11-events/65-warning-pause.md @@ -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, часы на паузе ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 8fffa95..7593297 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, 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 | diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index e43faba..3e42fb3 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -386,6 +386,8 @@ const ru = { DayStarted: 'Начало дня', LessonStarted: 'Начало урока', GenerationFailed: 'Не удалось нарисовать портрет', + noticeDismiss: 'Закрыть', + noticePauseLocked: 'Закройте предупреждение, чтобы продолжить', } as const; type Messages = { [K in keyof typeof ru]: string }; @@ -776,6 +778,8 @@ const en: Messages = { DayStarted: 'The day has started', LessonStarted: 'A lesson has started', GenerationFailed: 'Portrait generation failed', + noticeDismiss: 'Close', + noticePauseLocked: 'Close the warning to resume', }; const catalogs: Record = { ru, en }; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 6ff9edd..1ce2280 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -1634,3 +1634,41 @@ body { border-color: var(--accent); } +.notice-modals { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background: rgb(0 0 0 / 0.35); +} + +.notice-modals[hidden] { + display: none !important; +} + +.notice-modal { + pointer-events: auto; + display: flex; + flex-direction: column; + gap: 12px; + max-width: min(420px, calc(100% - 32px)); + margin: 16px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-raised); + box-shadow: 0 16px 40px rgb(0 0 0 / 0.45); +} + +.notice-modal--error { + border-color: var(--danger); +} + +.notice-modal__text { + margin: 0; + font-size: 15px; +} + diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index c1adf8c..7bb607a 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -16,6 +16,7 @@ import { fetchDirectory, type School } from '../net/api.ts'; import { clear, el } from './dom.ts'; import { ManagementPanel } from './managementPanel.ts'; import { NoticeToasts } from './noticeToasts.ts'; +import { NoticeModals } from './noticeModals.ts'; import { PeoplePanel } from './peoplePanel.ts'; import { locationPersonLine } from '../format/talkCircle.ts'; import { formatPersonPlace } from './personCard.ts'; @@ -87,6 +88,7 @@ export class GameScreen { }, }); private readonly notices: NoticeToasts; + private readonly noticeModals: NoticeModals; private readonly management = new ManagementPanel(); private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' }); private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' }); @@ -117,6 +119,11 @@ export class GameScreen { constructor(options: GameScreenOptions) { this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id)); + this.noticeModals = new NoticeModals( + (id) => options.onDismissNotice?.(id), + () => this.canManage, + () => this.paintPlay(), + ); this.speedButtons = CLOCK_SPEEDS.map((_, index) => el('button', { class: 'button button--small', @@ -148,6 +155,7 @@ export class GameScreen { this.overview, this.manage, this.notices.element, + this.noticeModals.element, ); this.overview.append( @@ -211,6 +219,7 @@ export class GameScreen { this.people.localize(); this.management.localize(); this.notices.localize(); + this.noticeModals.localize(); this.paintSelection(); if (this.lastGameTime !== null) { @@ -252,6 +261,7 @@ export class GameScreen { this.skipAllowed = false; this.skipTarget = null; this.notices.clear(); + this.noticeModals.clear(); this.rebuildTree(); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); @@ -316,6 +326,11 @@ export class GameScreen { return; } + if (message.pause || message.severity !== 0) { + this.noticeModals.show(message); + return; + } + this.notices.show(message); } @@ -502,6 +517,7 @@ export class GameScreen { this.playPauseButton.textContent = running ? '⏸' : '▶'; this.playPauseButton.title = running ? t('pause') : t('resume'); + this.paintPlay(); this.speedButtons.forEach((button, index) => { button.classList.toggle('button--active', index === speedIndex); @@ -517,6 +533,14 @@ export class GameScreen { : ''; } + private paintPlay(): void { + const blocked = this.noticeModals.blocksPlay; + this.playPauseButton.disabled = blocked; + if (blocked && !this.running) { + this.playPauseButton.title = t('noticePauseLocked'); + } + } + private paintSeed(): void { this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed }); } diff --git a/src/HSchool.Client/src/ui/noticeModals.test.ts b/src/HSchool.Client/src/ui/noticeModals.test.ts new file mode 100644 index 0000000..0df93e7 --- /dev/null +++ b/src/HSchool.Client/src/ui/noticeModals.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { NoticeModals } from './noticeModals.ts'; + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + setLocale('ru'); +}); + +function pausing(id: number, defName = 'GenerationFailed'): Parameters[0] { + return { + type: 'notice', + id, + defName, + severity: 2, + pause: true, + ttlMs: 8000, + personId: 0, + }; +} + +describe('NoticeModals', () => { + it('does not hide on TTL', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + const modals = new NoticeModals(onDismiss, () => true, () => {}); + document.body.append(modals.element); + + modals.show(pausing(4)); + expect(modals.element.querySelector('.notice-modal__text')?.textContent).toBe(t('GenerationFailed')); + + vi.advanceTimersByTime(8000); + expect(onDismiss).not.toHaveBeenCalled(); + expect(modals.element.querySelector('.notice-modal')).not.toBeNull(); + expect(modals.open).toBe(true); + }); + + it('shows the next queued notice after close', () => { + const onDismiss = vi.fn(); + const modals = new NoticeModals(onDismiss, () => true, () => {}); + document.body.append(modals.element); + + modals.show(pausing(1, 'GenerationFailed')); + modals.show({ ...pausing(2, 'GenerationFailed') }); + + expect(modals.element.querySelectorAll('.notice-modal')).toHaveLength(1); + expect(modals.element.querySelector('[data-notice-id="1"]')).not.toBeNull(); + + modals.element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onDismiss).toHaveBeenCalledWith(1); + expect(modals.element.querySelector('[data-notice-id="2"]')).not.toBeNull(); + expect(modals.element.querySelector('[data-notice-id="1"]')).toBeNull(); + }); + + it('hides the close button when the viewer cannot dismiss', () => { + const onDismiss = vi.fn(); + const modals = new NoticeModals(onDismiss, () => false, () => {}); + document.body.append(modals.element); + + modals.show(pausing(9)); + expect(modals.element.querySelector('button')).toBeNull(); + expect(modals.element.querySelector('.notice-modal')).not.toBeNull(); + }); +}); diff --git a/src/HSchool.Client/src/ui/noticeModals.ts b/src/HSchool.Client/src/ui/noticeModals.ts new file mode 100644 index 0000000..26d47d7 --- /dev/null +++ b/src/HSchool.Client/src/ui/noticeModals.ts @@ -0,0 +1,87 @@ +import type { NoticeMessage } from '../net/protocol.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import { noticeLabel } from './noticeToasts.ts'; + +/** + * One pausing warning/error at a time. Not a toast: no TTL, a queue, close is dismiss. + * Guests see the card and have no close button — only the owner can clear the pause. + */ +export class NoticeModals { + readonly element = el('div', { class: 'notice-modals', hidden: true }); + private readonly queue: NoticeMessage[] = []; + + constructor( + private readonly onDismiss: (id: number) => void, + private readonly canDismiss: () => boolean, + private readonly onChange: () => void, + ) {} + + get open(): boolean { + return this.queue.length > 0; + } + + get blocksPlay(): boolean { + return this.queue.some((notice) => notice.pause); + } + + show(notice: NoticeMessage): void { + if (this.queue.some((item) => item.id === notice.id)) { + return; + } + + this.queue.push(notice); + this.paint(); + this.onChange(); + } + + clear(): void { + this.queue.length = 0; + this.paint(); + this.onChange(); + } + + localize(): void { + this.paint(); + } + + private dismissCurrent(): void { + const current = this.queue.shift(); + if (current === undefined) { + return; + } + + this.onDismiss(current.id); + this.paint(); + this.onChange(); + } + + private paint(): void { + const current = this.queue[0]; + if (current === undefined) { + this.element.hidden = true; + this.element.replaceChildren(); + return; + } + + this.element.hidden = false; + const close = this.canDismiss() + ? el('button', { + class: 'button', + type: 'button', + text: t('noticeDismiss' satisfies MessageKey), + onClick: () => this.dismissCurrent(), + }) + : null; + const card = el( + 'div', + { + class: current.severity === 2 ? 'notice-modal notice-modal--error' : 'notice-modal notice-modal--warning', + dataset: { noticeId: String(current.id), noticeDef: current.defName }, + }, + el('p', { class: 'notice-modal__text', text: noticeLabel(current.defName) }), + close, + ); + this.element.replaceChildren(card); + } +} diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts index 58aff04..85499e4 100644 --- a/src/HSchool.Client/src/ui/noticeToasts.ts +++ b/src/HSchool.Client/src/ui/noticeToasts.ts @@ -4,7 +4,7 @@ import { el } from './dom.ts'; const MAX_INFO_TOASTS = 8; -function label(defName: string): string { +export function noticeLabel(defName: string): string { switch (defName) { case 'DayStarted': case 'LessonStarted': @@ -42,7 +42,7 @@ export class NoticeToasts { { class: 'notice-toast', type: 'button', - text: label(notice.defName), + text: noticeLabel(notice.defName), dataset: { noticeId: String(notice.id), noticeDef: notice.defName }, onClick: () => this.remove(notice.id, true), }, @@ -69,7 +69,7 @@ export class NoticeToasts { for (const node of this.element.querySelectorAll('[data-notice-def]')) { const defName = node.dataset.noticeDef; if (defName !== undefined) { - node.textContent = label(defName); + node.textContent = noticeLabel(defName); } } } diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index b545d02..03cff5a 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -84,7 +84,7 @@ public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyListJump empty nights, weekends and holidays. The server re-checks the conditions. public readonly record struct ClientSkipEmptyMessage; -/// Closes one notice by id. Info is not stored; pausing dismiss is phase 65. +/// Closes one notice by id. Info is not stored; pausing dismiss is owner-only. public readonly record struct ClientDismissNoticeMessage(uint Id); /// Wire values for . @@ -97,7 +97,7 @@ public static class NoticeSeverity /// /// One school event for open clients. is 0 when nobody is in frame. -/// Info toasts are not replayed on OpenSchool. +/// Info toasts are not replayed on OpenSchool. Pausing notices are saved and resent on Open. /// public readonly record struct ServerNoticeMessage( uint Id, diff --git a/src/HSchool.Server/Api/DevEndpoints.cs b/src/HSchool.Server/Api/DevEndpoints.cs index dc95e8e..c36c0b2 100644 --- a/src/HSchool.Server/Api/DevEndpoints.cs +++ b/src/HSchool.Server/Api/DevEndpoints.cs @@ -38,6 +38,43 @@ internal static class DevEndpoints return Results.Ok(MapDump(published, live)); }) .WithName("DumpSchool"); + + builder.MapPost("/api/dev/schools/{id:int}/notices", async ( + int id, + PostDevNoticeRequest? request, + GameLoopService loop, + GameCommandQueue commands, + CancellationToken cancellationToken) => + { + if (loop.FindSchool(id) is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + var defName = request?.DefName?.Trim() ?? ""; + if (defName.Length == 0) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "defName is required."); + } + + var command = new GameCommand.PostSchoolNotice( + id, + defName, + request?.PersonId ?? 0, + NewCompletion()); + commands.Enqueue(command); + var posted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + if (posted is null) + { + return Problem( + StatusCodes.Status409Conflict, + "notice-rejected", + "Unknown event def or the sticky notice ceiling is full."); + } + + return Results.Ok(new { id = posted.Id, defName = posted.DefName, pause = posted.Pause }); + }) + .WithName("PostDevNotice"); } private static SchoolDumpResponse MapDump(PublishedSchoolPeople published, SchoolLiveDump live) @@ -115,3 +152,5 @@ internal sealed record SchoolDumpLessonResponse( string RoomId, int Day, int Period); + +internal sealed record PostDevNoticeRequest(string? DefName, uint PersonId); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 71c5604..533f474 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -700,6 +700,41 @@ internal static class SchoolEndpoints return StaffingResult(id, outcome, loop, ParseLocale(lang)); }) .WithName("UnassignSchoolSubject"); + + schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async ( + int id, + long noticeId, + HttpContext context, + SessionService sessions, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } + + var denied = SchoolAccess.RequireManage(loop, id, normalizedUser); + if (denied is not null) + { + return denied; + } + + if (noticeId is < 1 or > uint.MaxValue) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "Notice id is out of range."); + } + + var command = new GameCommand.DismissSchoolNotice( + id, + (uint)noticeId, + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + commands.Enqueue(command); + await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return Results.NoContent(); + }) + .WithName("DismissSchoolNotice"); } /// The supervisor must never be blocked by a continuation of a waiting request. diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 3bc4d17..fe99fef 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -42,7 +42,15 @@ internal abstract record GameCommand internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : GameCommand; - internal sealed record DismissNotice(uint PlayerId, uint NoticeId) : GameCommand; + internal sealed record DismissNotice(uint PlayerId, uint NoticeId, string NormalizedUserName) : GameCommand; + + internal sealed record DismissSchoolNotice(int SchoolId, uint NoticeId, TaskCompletionSource Result) : GameCommand; + + internal sealed record PostSchoolNotice( + int SchoolId, + string DefName, + uint PersonId, + TaskCompletionSource Result) : GameCommand; /// Stops every worker, re-reads the save directory, starts workers from those files. internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 26d6684..c4ecec8 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -226,7 +226,15 @@ internal sealed class GameLoopService( break; case GameCommand.DismissNotice dismiss: - RouteOpenSchool(dismiss.PlayerId, new WorkerCommand.DismissNotice(dismiss.NoticeId)); + HandleClockCommand(dismiss.PlayerId, dismiss.NormalizedUserName, new WorkerCommand.DismissNotice(dismiss.NoticeId)); + break; + + case GameCommand.DismissSchoolNotice dismissHttp: + HandleDismissHttp(dismissHttp); + break; + + case GameCommand.PostSchoolNotice postNotice: + HandlePostNotice(postNotice); break; case GameCommand.ReloadSaves reload: @@ -639,17 +647,6 @@ internal sealed class GameLoopService( } } - private void RouteOpenSchool(uint playerId, WorkerCommand command) - { - var client = clients.Find(playerId); - if (client?.OpenSchoolId is not { } schoolId) - { - return; - } - - Route(schoolId, command); - } - private void HandleClockCommand(uint playerId, string normalizedUserName, WorkerCommand command) { var client = clients.Find(playerId); @@ -667,6 +664,24 @@ internal sealed class GameLoopService( Route(schoolId, command); } + private void HandleDismissHttp(GameCommand.DismissSchoolNotice command) + { + if (!_workers.TryGetValue(command.SchoolId, out var worker) + || !worker.Post(new WorkerCommand.DismissNoticeHttp(command.NoticeId, command.Result))) + { + command.Result.TrySetResult(); + } + } + + private void HandlePostNotice(GameCommand.PostSchoolNotice command) + { + if (!_workers.TryGetValue(command.SchoolId, out var worker) + || !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonId, command.Result))) + { + command.Result.TrySetResult(null); + } + } + private async Task StartWorkersFromDiskAsync() { var saves = store.LoadAll(); @@ -725,7 +740,8 @@ internal sealed class GameLoopService( save.DressRules, save.SpeechRules, save.Owner, - save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings)); + save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings), + save.Notices); worker.Start(); try @@ -784,7 +800,8 @@ internal sealed class GameLoopService( SchoolDressRules? dressRules = null, SchoolSpeechRules? speechRules = null, string? owner = null, - SwarmUiConfigFile? portraitSettings = null) => + SwarmUiConfigFile? portraitSettings = null, + IReadOnlyList? notices = null) => new( id, name, @@ -803,6 +820,7 @@ internal sealed class GameLoopService( speechRules, owner, portraitSettings, + notices, _options, clients, metrics, diff --git a/src/HSchool.Server/Game/NoticeBoard.cs b/src/HSchool.Server/Game/NoticeBoard.cs new file mode 100644 index 0000000..3bfc55a --- /dev/null +++ b/src/HSchool.Server/Game/NoticeBoard.cs @@ -0,0 +1,141 @@ +using HSchool.Content; +using HSchool.Protocol; + +namespace HSchool.Server.Game; + +/// Unclosed warning/error notices. Info is never stored here. +internal sealed class StickyNotice +{ + public required uint Id { get; init; } + + public required string DefName { get; init; } + + public required byte Severity { get; init; } + + public required bool Pause { get; init; } + + public required uint TtlMs { get; init; } + + public required uint PersonId { get; init; } + + public ServerNoticeMessage ToMessage() => new(Id, DefName, Severity, Pause, TtlMs, PersonId); + + public StickyNoticeSave ToSave() => new() + { + Id = Id, + DefName = DefName, + Severity = Severity, + Pause = Pause, + TtlMs = TtlMs, + PersonId = PersonId, + }; + + public static byte WireSeverity(string severity) => severity switch + { + EventSeverities.Warning => NoticeSeverity.Warning, + EventSeverities.Error => NoticeSeverity.Error, + _ => NoticeSeverity.Info, + }; +} + +/// +/// School-side queue of pausing notices. Ceiling is . +/// The ninth is not posted. Dismiss does not start the clock. +/// +internal sealed class NoticeBoard +{ + private readonly List _sticky = []; + private uint _nextId; + + public NoticeBoard(int maxSticky, IReadOnlyList? saved = null) + { + MaxSticky = Math.Max(1, maxSticky); + if (saved is null) + { + return; + } + + foreach (var row in saved) + { + if (!row.Pause || string.IsNullOrWhiteSpace(row.DefName) || row.Id == 0) + { + continue; + } + + if (_sticky.Count >= MaxSticky) + { + break; + } + + _sticky.Add(new StickyNotice + { + Id = row.Id, + DefName = row.DefName, + Severity = row.Severity, + Pause = true, + TtlMs = row.TtlMs, + PersonId = row.PersonId, + }); + if (row.Id > _nextId) + { + _nextId = row.Id; + } + } + } + + public int MaxSticky { get; } + + public bool HasPausing => _sticky.Count > 0; + + public IReadOnlyList Sticky => _sticky; + + public IReadOnlyList ToSave() => [.. _sticky.Select(notice => notice.ToSave())]; + + /// + /// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast + /// only. Returns when the sticky ceiling would be exceeded. + /// + public bool TryPost(EventDef def, uint personId, out ServerNoticeMessage message) + { + message = default; + if (def.Pause && _sticky.Count >= MaxSticky) + { + return false; + } + + var id = ++_nextId; + message = new ServerNoticeMessage( + id, + def.DefName, + StickyNotice.WireSeverity(def.Severity), + def.Pause, + (uint)Math.Max(0, def.TtlMs), + personId); + if (def.Pause) + { + _sticky.Add(new StickyNotice + { + Id = id, + DefName = def.DefName, + Severity = message.Severity, + Pause = true, + TtlMs = message.TtlMs, + PersonId = personId, + }); + } + + return true; + } + + public bool TryDismiss(uint id) + { + var index = _sticky.FindIndex(notice => notice.Id == id); + if (index < 0) + { + return false; + } + + _sticky.RemoveAt(index); + return true; + } +} diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 6ce9b15..74dafd3 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -46,6 +46,25 @@ internal sealed class SchoolSave /// Portrait presets copied at create. Generation reads this, not the global template. public SwarmUiConfigFile? PortraitSettings { get; init; } + + /// Unclosed pausing notices. Info is never written here. + public IReadOnlyList? Notices { get; init; } +} + +/// One sticky warning/error kept across F5 and worker restart. +internal sealed class StickyNoticeSave +{ + public uint Id { get; init; } + + public string DefName { get; init; } = ""; + + public byte Severity { get; init; } + + public bool Pause { get; init; } + + public uint TtlMs { get; init; } + + public uint PersonId { get; init; } } /// Allocates school ids that survive a process restart. @@ -193,6 +212,7 @@ internal sealed class SchoolStore SpeechRules = save.SpeechRules, Owner = save.Owner, PortraitSettings = save.PortraitSettings, + Notices = save.Notices, }); } catch (Exception ex) @@ -242,6 +262,7 @@ internal sealed class SchoolStore SpeechRules = save.SpeechRules, Owner = save.Owner, PortraitSettings = save.PortraitSettings, + Notices = save.Notices, }; } diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs index 69fdb9d..983c0cc 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -1,7 +1,9 @@ using HSchool.Content; using HSchool.People; +using HSchool.Protocol; using HSchool.Schedule; using HSchool.Server.Api; +using HSchool.Server.Net; using HSchool.Simulation; namespace HSchool.Server.Game; @@ -36,6 +38,7 @@ internal sealed partial class SchoolWorker SendMapSnapshot(open.Client, school); BroadcastClockTo(open.Client, school); SendPresence(open.Client, school); + SendStickyNotices(open.Client); break; case WorkerCommand.Close close: @@ -48,6 +51,11 @@ internal sealed partial class SchoolWorker break; case WorkerCommand.SetRunning setRunning: + if (setRunning.Running && _notices.HasPausing) + { + break; + } + school.Clock.IsRunning = setRunning.Running; dirty = true; break; @@ -61,8 +69,36 @@ internal sealed partial class SchoolWorker ApplySkip(school); break; - case WorkerCommand.DismissNotice: - // Info is not stored; pausing dismiss is phase 65. + case WorkerCommand.DismissNotice dismiss: + if (_notices.TryDismiss(dismiss.Id)) + { + Persist(); + } + + break; + + case WorkerCommand.PostNotice post: + { + EventDef? def = null; + if (school.Catalog is not null) + { + school.Catalog.Events.TryGetValue(post.DefName, out def); + } + + if (def is null || def.Abstract) + { + post.Result.TrySetResult(null); + break; + } + + post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonId)); + break; + } + + case WorkerCommand.DismissNoticeHttp dismissHttp: + _notices.TryDismiss(dismissHttp.Id); + Persist(); + dismissHttp.Result.TrySetResult(); break; case WorkerCommand.Dump dump: @@ -184,6 +220,12 @@ internal sealed partial class SchoolWorker case WorkerCommand.Dump dump: dump.Result.TrySetResult(null); break; + case WorkerCommand.PostNotice post: + post.Result.TrySetResult(null); + break; + case WorkerCommand.DismissNoticeHttp dismissHttp: + dismissHttp.Result.TrySetResult(); + break; case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); break; @@ -227,6 +269,12 @@ internal sealed partial class SchoolWorker case WorkerCommand.Dump dump: dump.Result.TrySetException(exception); break; + case WorkerCommand.PostNotice post: + post.Result.TrySetException(exception); + break; + case WorkerCommand.DismissNoticeHttp dismissHttp: + dismissHttp.Result.TrySetException(exception); + break; case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetException(exception); break; @@ -380,4 +428,34 @@ internal sealed partial class SchoolWorker ApplyTable(school, next, broadcast: true); return TimetableOutcome.Ok(next); } + + private PostedNotice? TryEmitNotice(School school, EventDef def, uint personId) + { + if (!_notices.TryPost(def, personId, out var message)) + { + return null; + } + + BroadcastNotice(message); + if (def.Pause) + { + school.Clock.IsRunning = false; + PublishSnapshot(); + Persist(); + BroadcastClock(); + } + + return new PostedNotice(message.Id, message.DefName, message.Pause); + } + + private void SendStickyNotices(GameClient client) + { + foreach (var notice in _notices.Sticky) + { + var message = notice.ToMessage(); + var frame = new byte[ProtocolCodec.NoticeSize(message)]; + var length = ProtocolCodec.WriteNotice(frame, message); + client.TrySendReliable(frame.AsMemory(0, length)); + } + } } diff --git a/src/HSchool.Server/Game/SchoolWorker.Persist.cs b/src/HSchool.Server/Game/SchoolWorker.Persist.cs index 395bf65..b3c0ad6 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Persist.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Persist.cs @@ -210,6 +210,7 @@ internal sealed partial class SchoolWorker SpeechRules = school.SpeechRules, Owner = _owner, PortraitSettings = _portraitSettings, + Notices = _notices.ToSave(), }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs index 88caee6..f8a553d 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs @@ -95,6 +95,11 @@ internal sealed partial class SchoolWorker _school = school; school.DressRules = _savedDressRules ?? new SchoolDressRules(); school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules(); + if (_notices.HasPausing) + { + school.Clock.IsRunning = false; + } + PublishSnapshot(); if (_isNew) @@ -505,19 +510,12 @@ internal sealed partial class SchoolWorker { foreach (var def in catalog.Events.Values) { - if (def.Abstract - || !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal) - || !def.Severity.Equals(EventSeverities.Info, StringComparison.Ordinal)) + if (def.Abstract || !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal)) { continue; } - BroadcastNotice(new ServerNoticeMessage( - ++_nextNoticeId, - def.DefName, - NoticeSeverity.Info, - def.Pause, - (uint)Math.Max(0, def.TtlMs))); + TryEmitNotice(school, def, personId: 0); } } } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index d169e1a..324a931 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -53,7 +53,7 @@ internal sealed partial class SchoolWorker private Timetable? _timetableSnapshot; private MapLayout? _mapSnapshot; private int _presenceAge; - private uint _nextNoticeId; + private readonly NoticeBoard _notices; private School? _school; private Task? _run; private bool _persistOnStop = true; @@ -78,6 +78,7 @@ internal sealed partial class SchoolWorker SchoolSpeechRules? savedSpeechRules, string? owner, SwarmUiConfigFile? portraitSettings, + IReadOnlyList? savedNotices, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, @@ -110,7 +111,8 @@ internal sealed partial class SchoolWorker _mods = mods; _onFailed = onFailed; _logger = logger; - _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? [], createSeed ?? 0, _owner); + _notices = new NoticeBoard(options.MaxStickyNotices, savedNotices); + _snapshot = new SchoolState(id, name, time, running && !_notices.HasPausing, (byte)speedIndex, modIds ?? [], createSeed ?? 0, _owner); } public int Id => _id; diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index ae16b8d..9f44e46 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -24,6 +24,14 @@ internal abstract record WorkerCommand internal sealed record DismissNotice(uint Id) : WorkerCommand; + internal sealed record DismissNoticeHttp(uint Id, TaskCompletionSource Result) : WorkerCommand; + + /// Test/dev post onto the school board. Looks up an EventDef by name. + internal sealed record PostNotice( + string DefName, + uint PersonId, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record Dump(TaskCompletionSource Result) : WorkerCommand; internal sealed record GetPerson( @@ -81,3 +89,5 @@ internal abstract record WorkerCommand string? PendingStaff, TaskCompletionSource Result) : WorkerCommand; } + +internal sealed record PostedNotice(uint Id, string DefName, bool Pause); diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index be8a562..6a37c36 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -182,7 +182,11 @@ internal sealed class GameSocketHandler( case MessageType.ClientDismissNotice: var dismiss = ProtocolCodec.ReadDismissNotice(frame); - commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id)); + if (TryNormalizedUser(client, out var dismissUser)) + { + commands.Enqueue(new GameCommand.DismissNotice(client.PlayerId, dismiss.Id, dismissUser)); + } + break; default: diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 39b67c8..2dd0cbc 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -37,6 +37,7 @@ builder.Services .Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.") .Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.") .Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.") + .Validate(options => options.MaxStickyNotices is > 0 and <= 32, "Simulation:MaxStickyNotices must be between 1 and 32.") .ValidateOnStart(); builder.Services.AddSingleton(); diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index 8b41b7f..8755527 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -64,6 +64,11 @@ public sealed class SimulationOptions /// public int MaxDecisionsPerTick { get; set; } = 64; + /// + /// Unclosed warning/error notices kept in the save. The next one is not posted. + /// + public int MaxStickyNotices { get; set; } = 8; + /// /// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day. /// diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 033cb48..3ee136e 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -121,6 +121,177 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Null(replayed); } + [Fact] + public async Task PausingNotice_SetsRunningFalse_AndIgnoresSetRunningTrue() + { + using var client = await CreateOwnerHttpClientAsync(); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Пауза варнингом", StartDate); + + using var socket = await OpenSchoolAsync(school.Id, client); + await ReceiveClockWhereAsync(socket, clock => clock.Running); + + var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed"); + var notice = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + + Assert.Equal(posted.Id, notice.Id); + Assert.Equal("GenerationFailed", notice.DefName); + Assert.True(notice.Pause); + Assert.Equal(NoticeSeverity.Error, notice.Severity); + + var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running); + await SendAsync(socket, buffer => + ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); + var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1)); + + Assert.False(later.Running); + Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs); + } + + [Fact] + public async Task DismissLastPausing_AllowsSetRunningTrue_DoesNotAutoPlay() + { + using var client = await CreateOwnerHttpClientAsync(); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Dismiss не Play", StartDate); + + using var socket = await OpenSchoolAsync(school.Id, client); + await ReceiveClockWhereAsync(socket, clock => clock.Running); + + var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed"); + await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)); + var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running); + + await SendAsync(socket, buffer => + ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id))); + var stillPaused = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1)); + Assert.False(stillPaused.Running); + Assert.Equal(paused.GameTimeUnixMs, stillPaused.GameTimeUnixMs); + + await SendAsync(socket, buffer => + ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); + var resumed = await ReceiveClockWhereAsync(socket, clock => clock.Running); + Assert.True(resumed.Running); + } + + [Fact] + public async Task SaveLoad_KeepsSticky_DropsInfo() + { + using var client = await CreateOwnerHttpClientAsync(); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Sticky сейв", StartDate); + + using (var socket = await OpenSchoolAsync(school.Id, client)) + { + await ReceiveClockAsync(socket); + await PostDevNoticeAsync(client, school.Id, "DayStarted"); + var info = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + Assert.Equal("DayStarted", info.DefName); + Assert.False(info.Pause); + + await PostDevNoticeAsync(client, school.Id, "GenerationFailed"); + var sticky = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + Assert.Equal("GenerationFailed", sticky.DefName); + await ReceiveClockWhereAsync(socket, clock => !clock.Running); + } + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var restored = await FindAsync(client, school.Id); + Assert.False(restored.Running); + + using var reopened = await OpenSchoolAsync(school.Id, client); + var replayed = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + Assert.Equal("GenerationFailed", replayed.DefName); + Assert.True(replayed.Pause); + Assert.False((await ReceiveClockAsync(reopened)).Running); + + var extra = await TryReceiveNoticeAsync(reopened, TimeSpan.FromSeconds(2)); + Assert.True(extra is not { DefName: "DayStarted" }); + } + + [Fact] + public async Task Guest_Dismiss_DoesNotClearPause_OwnerDoes() + { + using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseOwner"); + await SchoolApiTests.WipeAllSavesAsync(ownerClient); + var school = await SchoolApiTests.CreateAsync(ownerClient, "Гость не закрывает", StartDate); + + using var ownerSocket = await OpenSchoolAsync(school.Id, ownerClient); + await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running); + var posted = await PostDevNoticeAsync(ownerClient, school.Id, "GenerationFailed"); + await ReceiveUntilAsync(ownerSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)); + await ReceiveClockWhereAsync(ownerSocket, clock => !clock.Running); + + using var guestHttp = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseGuest"); + using var forbidden = await guestHttp.PostAsync( + $"/api/schools/{school.Id}/notices/{posted.Id}/dismiss", + content: null, + TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Forbidden, forbidden.StatusCode); + Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(forbidden)); + + var guestCookie = guestHttp.DefaultRequestHeaders.TryGetValues("Cookie", out var guestCookies) + ? guestCookies.First() + : await SchoolApiTests.WebSocketCookieAsync(guestHttp); + using var guestSocket = await ConnectWithCookieAsync(guestCookie); + await ReceiveUntilAsync(guestSocket, MessageType.ServerWelcome); + await SendAsync(guestSocket, buffer => + ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id))); + var guestNotice = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(guestSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + Assert.Equal(posted.Id, guestNotice.Id); + + await SendAsync(guestSocket, buffer => + ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id))); + await Task.Delay(TimeSpan.FromMilliseconds(400), TestContext.Current.CancellationToken); + Assert.False((await FindAsync(ownerClient, school.Id)).Running); + + await SendAsync(ownerSocket, buffer => + ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id))); + await SendAsync(ownerSocket, buffer => + ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); + var resumed = await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running); + Assert.True(resumed.Running); + + using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); + delete.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task OpenAfterReload_ReplaysSticky_ClockPaused() + { + using var client = await CreateOwnerHttpClientAsync(); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "F5 sticky", StartDate); + + using (var socket = await OpenSchoolAsync(school.Id, client)) + { + await ReceiveClockAsync(socket); + await PostDevNoticeAsync(client, school.Id, "GenerationFailed"); + await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)); + await ReceiveClockWhereAsync(socket, clock => !clock.Running); + } + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + using var reopened = await OpenSchoolAsync(school.Id, client); + var notice = ProtocolCodec.ReadNotice( + await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10))); + var clock = await ReceiveClockWhereAsync(reopened, frame => !frame.Running); + + Assert.Equal("GenerationFailed", notice.DefName); + Assert.True(notice.Pause); + Assert.False(clock.Running); + Assert.False((await FindAsync(client, school.Id)).Running); + } + [Fact] public async Task OpeningASchool_SendsAMapSnapshot() { @@ -539,6 +710,18 @@ public class GameSocketTests(AppHostFixture fixture) private static DateTime ToDate(ServerClockMessage clock) => DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime; + private static async Task PostDevNoticeAsync(HttpClient client, int schoolId, string defName) + { + using var response = await client.PostAsJsonAsync( + $"/api/dev/schools/{schoolId}/notices", + new { defName, personId = 0u }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + return body; + } + private async Task FindAsync(HttpClient client, int schoolId) { var state = await SchoolApiTests.GetSchoolsAsync(client); @@ -784,4 +967,6 @@ public class GameSocketTests(AppHostFixture fixture) private sealed record DirectorySnapshot(IReadOnlyList People); private sealed record DirectoryPersonSnapshot(string Id, string FullName); + + private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause); } diff --git a/tests/HSchool.Content.Tests/EventDefTests.cs b/tests/HSchool.Content.Tests/EventDefTests.cs index bff8893..46b7f87 100644 --- a/tests/HSchool.Content.Tests/EventDefTests.cs +++ b/tests/HSchool.Content.Tests/EventDefTests.cs @@ -26,7 +26,11 @@ public class EventDefTests Assert.Equal(EventActions.None, catalog.Events["LessonStarted"].Action); Assert.True(catalog.Events.ContainsKey("GenerationFailed")); + Assert.Equal(EventSeverities.Error, catalog.Events["GenerationFailed"].Severity); + Assert.True(catalog.Events["GenerationFailed"].Pause); + Assert.Equal(0, catalog.Events["GenerationFailed"].TtlMs); Assert.Equal(EventTriggers.GenerationFailed, catalog.Events["GenerationFailed"].Trigger); + Assert.Equal(EventActions.None, catalog.Events["GenerationFailed"].Action); Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"])); Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"])); } diff --git a/tests/HSchool.Server.Tests/NoticeBoardTests.cs b/tests/HSchool.Server.Tests/NoticeBoardTests.cs new file mode 100644 index 0000000..e2f711b --- /dev/null +++ b/tests/HSchool.Server.Tests/NoticeBoardTests.cs @@ -0,0 +1,93 @@ +using HSchool.Content; +using HSchool.Protocol; +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class NoticeBoardTests +{ + [Fact] + public void PausingPost_IsSticky_InfoIsNot() + { + var board = new NoticeBoard(maxSticky: 8); + + Assert.True(board.TryPost(Info(), personId: 0, out var info)); + Assert.False(info.Pause); + Assert.False(board.HasPausing); + Assert.Empty(board.Sticky); + + Assert.True(board.TryPost(Pausing(), personId: 0, out var warning)); + Assert.True(warning.Pause); + Assert.True(board.HasPausing); + Assert.Equal("GenerationFailed", Assert.Single(board.Sticky).DefName); + Assert.DoesNotContain(board.ToSave(), row => row.DefName == "DayStarted"); + } + + [Fact] + public void NinthSticky_IsNotPosted() + { + var board = new NoticeBoard(maxSticky: 8); + for (var i = 0; i < 8; i++) + { + Assert.True(board.TryPost(Pausing(), personId: 0, out _)); + } + + Assert.False(board.TryPost(Pausing(), personId: 0, out var ninth)); + Assert.Equal(default, ninth); + Assert.Equal(8, board.Sticky.Count); + } + + [Fact] + public void Dismiss_RemovesSticky_DoesNotClearTheBoardUntilTheLast() + { + var board = new NoticeBoard(maxSticky: 8); + Assert.True(board.TryPost(Pausing(), personId: 0, out var first)); + Assert.True(board.TryPost(Pausing(), personId: 0, out var second)); + + Assert.True(board.TryDismiss(first.Id)); + Assert.True(board.HasPausing); + Assert.Equal(second.Id, Assert.Single(board.Sticky).Id); + + Assert.True(board.TryDismiss(second.Id)); + Assert.False(board.HasPausing); + Assert.False(board.TryDismiss(second.Id)); + } + + [Fact] + public void Load_RestoresSticky_SkipsInfoRows() + { + var saved = new StickyNoticeSave[] + { + new() { Id = 3, DefName = "DayStarted", Severity = NoticeSeverity.Info, Pause = false, TtlMs = 8000 }, + new() { Id = 4, DefName = "GenerationFailed", Severity = NoticeSeverity.Error, Pause = true, TtlMs = 0 }, + }; + + var board = new NoticeBoard(maxSticky: 8, saved); + + Assert.True(board.HasPausing); + var sticky = Assert.Single(board.Sticky); + Assert.Equal(4u, sticky.Id); + Assert.Equal("GenerationFailed", sticky.DefName); + Assert.Equal(NoticeSeverity.Error, sticky.Severity); + } + + private static EventDef Info() => new() + { + DefName = "DayStarted", + Severity = EventSeverities.Info, + Pause = false, + TtlMs = 8000, + Trigger = EventTriggers.DayStart, + Action = EventActions.None, + }; + + private static EventDef Pausing() => new() + { + DefName = "GenerationFailed", + Severity = EventSeverities.Error, + Pause = true, + TtlMs = 0, + Trigger = EventTriggers.GenerationFailed, + Action = EventActions.None, + }; +} diff --git a/tests/HSchool.Server.Tests/SchoolStoreTests.cs b/tests/HSchool.Server.Tests/SchoolStoreTests.cs index 0d94b83..9d81f47 100644 --- a/tests/HSchool.Server.Tests/SchoolStoreTests.cs +++ b/tests/HSchool.Server.Tests/SchoolStoreTests.cs @@ -44,6 +44,62 @@ public class SchoolStoreTests } } + [Fact] + public void Save_RoundTripsStickyNotices_AndOmitsThemWhenMissing() + { + var directory = Directory.CreateTempSubdirectory("h-school-store-"); + try + { + var store = CreateStore(directory.FullName); + store.Save(new SchoolSave + { + Format = 3, + Id = 2, + Name = "Sticky", + GameTime = new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), + Running = false, + SpeedIndex = 1, + Notices = + [ + new StickyNoticeSave + { + Id = 9, + DefName = "GenerationFailed", + Severity = 2, + Pause = true, + TtlMs = 0, + }, + ], + }); + + var withNotices = Assert.Single(store.LoadAll()); + var sticky = Assert.Single(withNotices.Notices!); + Assert.Equal(9u, sticky.Id); + Assert.Equal("GenerationFailed", sticky.DefName); + Assert.True(sticky.Pause); + + File.WriteAllText( + Path.Combine(directory.FullName, "3.json"), + """ + { + "format": 3, + "id": 3, + "name": "Plain", + "gameTime": "2012-03-31T06:00:00Z", + "running": false, + "speedIndex": 0 + } + """); + + var plain = store.LoadAll().Single(save => save.Id == 3); + Assert.True(plain.Notices is null || plain.Notices.Count == 0); + } + finally + { + directory.Delete(recursive: true); + } + } + private static SchoolStore CreateStore(string directory) { var options = Options.Create(new SimulationOptions { SavesDirectory = directory });