Merge branch 'phase/65-warning-pause'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 23:40:26 +03:00
co-authored by Cursor
28 changed files with 994 additions and 52 deletions
+13 -13
View File
@@ -11,25 +11,25 @@
## Задачи ## Задачи
- [ ] `Notice` с `pause`: работник ставит `running=false`. `SetRunning(true)` игнор, пока есть - [x] `Notice` с `pause`: работник ставит `running=false`. `SetRunning(true)` игнор, пока есть
хотя бы одно такое уведомление (как нелегальный skip — не fatal) хотя бы одно такое уведомление (как нелегальный skip — не fatal)
- [ ] Модал (не тост): висит, очередь если несколько, на экране один. Закрытие — dismiss - [x] Модал (не тост): висит, очередь если несколько, на экране один. Закрытие — dismiss
- [ ] Sticky в сейве школы; после загрузки и Open — снова кадры. Info по-прежнему не сохранять - [x] Sticky в сейве школы; после загрузки и Open — снова кадры. Info по-прежнему не сохранять
- [ ] Dismiss pausing — только хозяин; гость кадр видит, dismiss игнор / HTTP `403` `not-owner` - [x] Dismiss pausing — только хозяин; гость кадр видит, dismiss игнор / HTTP `403` `not-owner`
- [ ] Потолок 8 sticky; девятый не эмитить. После последнего dismiss часы сами не запускать - [x] Потолок 8 sticky; девятый не эмитить. После последнего dismiss часы сами не запускать
- [ ] Ванильный `GenerationFailed` (error, pause) можно положить в каталог; эмит с портрета — - [x] Ванильный `GenerationFailed` (error, pause) можно положить в каталог; эмит с портрета —
фаза 67, здесь достаточно тестового/внутреннего поста на доску фаза 67, здесь достаточно тестового/внутреннего поста на доску
- [ ] [`protocol.md`](../../protocol.md) — поведение паузы и прав, без лишнего бампа версии - [x] [`protocol.md`](../../protocol.md) — поведение паузы и прав, без лишнего бампа версии
(версия уже +1 в 64) (версия уже +1 в 64)
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Пост pausing-notice: `running` становится false; последующий SetRunning(true) оставляет паузу - [x] Пост pausing-notice: `running` становится false; последующий SetRunning(true) оставляет паузу
- [ ] Dismiss последнего pausing: SetRunning(true) снова включает часы; без Play сами не идут - [x] Dismiss последнего pausing: SetRunning(true) снова включает часы; без Play сами не идут
- [ ] Сейв/загрузка: sticky на месте, info нет - [x] Сейв/загрузка: sticky на месте, info нет
- [ ] Гость не снимает паузу dismiss-ом; хозяин снимает - [x] Гость не снимает паузу dismiss-ом; хозяин снимает
- [ ] Клиентский: модал не гаснет по TTL; очередь показывает следующий после закрытия - [x] Клиентский: модал не гаснет по TTL; очередь показывает следующий после закрытия
- [ ] Хостовый: Open после рестарта работника отдаёт sticky, часы на паузе - [x] Хостовый: Open после рестарта работника отдаёт sticky, часы на паузе
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -19,7 +19,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [64. События и info-тост](64-event-defs.md) | ✅ | `EventDef`, утро и звонок, кадр на сокете, тост | | [64. События и info-тост](64-event-defs.md) | ✅ | `EventDef`, утро и звонок, кадр на сокете, тост |
| [65. Варнинг и пауза](65-warning-pause.md) | 🔄 | sticky, Play игнор, сейв, dismiss хозяина | | [65. Варнинг и пауза](65-warning-pause.md) | | sticky, Play игнор, сейв, dismiss хозяина |
63 независима. 65 стоит на 64. 63 независима. 65 стоит на 64.
+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 Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
`404` `unknown-lesson`. `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 ## Dev endpoints
These exist only when `HSchool:AllowSaveReload` is true (headless AppHost tests). They are never 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` `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. (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 ## WebSocket message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted 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 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. 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 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 ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (1), so ×1 is one game
minute per real second. minute per real second.
@@ -941,7 +959,8 @@ neighbouring field would clobber it with a stale client copy.
### `0x08` DismissNotice — 5 bytes ### `0x08` DismissNotice — 5 bytes
Closes one notice by id. Info toasts are not stored on the server; the frame still travels so a 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 | | Offset | Type | Field |
| --- | --- | --- | | --- | --- | --- |
@@ -1069,8 +1088,10 @@ on this frame; the client builds «говорит с Машей о футбол
### `0x87` Notice — variable ### `0x87` Notice — variable
One school event for every connection that has that school open. Not glued to the clock frame. 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 Info is not written to the save and is not resent on OpenSchool. Warning/error with `pause=1`
is in frame (the generate-image button is a later phase). 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 | | Offset | Type | Field |
| --- | --- | --- | | --- | --- | --- |
@@ -1078,7 +1099,7 @@ is in frame (the generate-image button is a later phase).
| 1 | `u32` | notice id | | 1 | `u32` | notice id |
| 5 | string | `defName` | | 5 | string | `defName` |
| … | `u8` | severity: `0` info, `1` warning, `2` error | | … | `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` | `ttlMs`; `0` stays until dismiss |
| … | `u32` | `personId`; `0` = none | | … | `u32` | `personId`; `0` = none |
+4
View File
@@ -386,6 +386,8 @@ const ru = {
DayStarted: 'Начало дня', DayStarted: 'Начало дня',
LessonStarted: 'Начало урока', LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет', GenerationFailed: 'Не удалось нарисовать портрет',
noticeDismiss: 'Закрыть',
noticePauseLocked: 'Закройте предупреждение, чтобы продолжить',
} as const; } as const;
type Messages = { [K in keyof typeof ru]: string }; type Messages = { [K in keyof typeof ru]: string };
@@ -776,6 +778,8 @@ const en: Messages = {
DayStarted: 'The day has started', DayStarted: 'The day has started',
LessonStarted: 'A lesson has started', LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed', GenerationFailed: 'Portrait generation failed',
noticeDismiss: 'Close',
noticePauseLocked: 'Close the warning to resume',
}; };
const catalogs: Record<Locale, Messages> = { ru, en }; const catalogs: Record<Locale, Messages> = { ru, en };
+38
View File
@@ -1634,3 +1634,41 @@ body {
border-color: var(--accent); 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;
}
+24
View File
@@ -16,6 +16,7 @@ import { fetchDirectory, type School } from '../net/api.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { ManagementPanel } from './managementPanel.ts'; import { ManagementPanel } from './managementPanel.ts';
import { NoticeToasts } from './noticeToasts.ts'; import { NoticeToasts } from './noticeToasts.ts';
import { NoticeModals } from './noticeModals.ts';
import { PeoplePanel } from './peoplePanel.ts'; import { PeoplePanel } from './peoplePanel.ts';
import { locationPersonLine } from '../format/talkCircle.ts'; import { locationPersonLine } from '../format/talkCircle.ts';
import { formatPersonPlace } from './personCard.ts'; import { formatPersonPlace } from './personCard.ts';
@@ -87,6 +88,7 @@ export class GameScreen {
}, },
}); });
private readonly notices: NoticeToasts; private readonly notices: NoticeToasts;
private readonly noticeModals: NoticeModals;
private readonly management = new ManagementPanel(); private readonly management = new ManagementPanel();
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' }); private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
private readonly manageTab = 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) { constructor(options: GameScreenOptions) {
this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id)); 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) => this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', { el('button', {
class: 'button button--small', class: 'button button--small',
@@ -148,6 +155,7 @@ export class GameScreen {
this.overview, this.overview,
this.manage, this.manage,
this.notices.element, this.notices.element,
this.noticeModals.element,
); );
this.overview.append( this.overview.append(
@@ -211,6 +219,7 @@ export class GameScreen {
this.people.localize(); this.people.localize();
this.management.localize(); this.management.localize();
this.notices.localize(); this.notices.localize();
this.noticeModals.localize();
this.paintSelection(); this.paintSelection();
if (this.lastGameTime !== null) { if (this.lastGameTime !== null) {
@@ -252,6 +261,7 @@ export class GameScreen {
this.skipAllowed = false; this.skipAllowed = false;
this.skipTarget = null; this.skipTarget = null;
this.notices.clear(); this.notices.clear();
this.noticeModals.clear();
this.rebuildTree(); this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
@@ -316,6 +326,11 @@ export class GameScreen {
return; return;
} }
if (message.pause || message.severity !== 0) {
this.noticeModals.show(message);
return;
}
this.notices.show(message); this.notices.show(message);
} }
@@ -502,6 +517,7 @@ export class GameScreen {
this.playPauseButton.textContent = running ? '⏸' : '▶'; this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? t('pause') : t('resume'); this.playPauseButton.title = running ? t('pause') : t('resume');
this.paintPlay();
this.speedButtons.forEach((button, index) => { this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex); 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 { private paintSeed(): void {
this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed }); this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed });
} }
@@ -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<NoticeModals['show']>[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();
});
});
+87
View File
@@ -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);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { el } from './dom.ts';
const MAX_INFO_TOASTS = 8; const MAX_INFO_TOASTS = 8;
function label(defName: string): string { export function noticeLabel(defName: string): string {
switch (defName) { switch (defName) {
case 'DayStarted': case 'DayStarted':
case 'LessonStarted': case 'LessonStarted':
@@ -42,7 +42,7 @@ export class NoticeToasts {
{ {
class: 'notice-toast', class: 'notice-toast',
type: 'button', type: 'button',
text: label(notice.defName), text: noticeLabel(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName }, dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
onClick: () => this.remove(notice.id, true), onClick: () => this.remove(notice.id, true),
}, },
@@ -69,7 +69,7 @@ export class NoticeToasts {
for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) { for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) {
const defName = node.dataset.noticeDef; const defName = node.dataset.noticeDef;
if (defName !== undefined) { if (defName !== undefined) {
node.textContent = label(defName); node.textContent = noticeLabel(defName);
} }
} }
} }
+2 -2
View File
@@ -84,7 +84,7 @@ public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSna
/// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary> /// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary>
public readonly record struct ClientSkipEmptyMessage; public readonly record struct ClientSkipEmptyMessage;
/// <summary>Closes one notice by id. Info is not stored; pausing dismiss is phase 65.</summary> /// <summary>Closes one notice by id. Info is not stored; pausing dismiss is owner-only.</summary>
public readonly record struct ClientDismissNoticeMessage(uint Id); public readonly record struct ClientDismissNoticeMessage(uint Id);
/// <summary>Wire values for <see cref="ServerNoticeMessage.Severity"/>.</summary> /// <summary>Wire values for <see cref="ServerNoticeMessage.Severity"/>.</summary>
@@ -97,7 +97,7 @@ public static class NoticeSeverity
/// <summary> /// <summary>
/// One school event for open clients. <paramref name="PersonId"/> is 0 when nobody is in frame. /// One school event for open clients. <paramref name="PersonId"/> 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.
/// </summary> /// </summary>
public readonly record struct ServerNoticeMessage( public readonly record struct ServerNoticeMessage(
uint Id, uint Id,
+39
View File
@@ -38,6 +38,43 @@ internal static class DevEndpoints
return Results.Ok(MapDump(published, live)); return Results.Ok(MapDump(published, live));
}) })
.WithName("DumpSchool"); .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<PostedNotice?>());
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) private static SchoolDumpResponse MapDump(PublishedSchoolPeople published, SchoolLiveDump live)
@@ -115,3 +152,5 @@ internal sealed record SchoolDumpLessonResponse(
string RoomId, string RoomId,
int Day, int Day,
int Period); int Period);
internal sealed record PostDevNoticeRequest(string? DefName, uint PersonId);
+35
View File
@@ -700,6 +700,41 @@ internal static class SchoolEndpoints
return StaffingResult(id, outcome, loop, ParseLocale(lang)); return StaffingResult(id, outcome, loop, ParseLocale(lang));
}) })
.WithName("UnassignSchoolSubject"); .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");
} }
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary> /// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
+9 -1
View File
@@ -42,7 +42,15 @@ internal abstract record GameCommand
internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : 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<PostedNotice?> Result) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary> /// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
+32 -14
View File
@@ -226,7 +226,15 @@ internal sealed class GameLoopService(
break; break;
case GameCommand.DismissNotice dismiss: 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; break;
case GameCommand.ReloadSaves reload: 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) private void HandleClockCommand(uint playerId, string normalizedUserName, WorkerCommand command)
{ {
var client = clients.Find(playerId); var client = clients.Find(playerId);
@@ -667,6 +664,24 @@ internal sealed class GameLoopService(
Route(schoolId, command); 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() private async Task StartWorkersFromDiskAsync()
{ {
var saves = store.LoadAll(); var saves = store.LoadAll();
@@ -725,7 +740,8 @@ internal sealed class GameLoopService(
save.DressRules, save.DressRules,
save.SpeechRules, save.SpeechRules,
save.Owner, save.Owner,
save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings)); save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings),
save.Notices);
worker.Start(); worker.Start();
try try
@@ -784,7 +800,8 @@ internal sealed class GameLoopService(
SchoolDressRules? dressRules = null, SchoolDressRules? dressRules = null,
SchoolSpeechRules? speechRules = null, SchoolSpeechRules? speechRules = null,
string? owner = null, string? owner = null,
SwarmUiConfigFile? portraitSettings = null) => SwarmUiConfigFile? portraitSettings = null,
IReadOnlyList<StickyNoticeSave>? notices = null) =>
new( new(
id, id,
name, name,
@@ -803,6 +820,7 @@ internal sealed class GameLoopService(
speechRules, speechRules,
owner, owner,
portraitSettings, portraitSettings,
notices,
_options, _options,
clients, clients,
metrics, metrics,
+141
View File
@@ -0,0 +1,141 @@
using HSchool.Content;
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>Unclosed warning/error notices. Info is never stored here.</summary>
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,
};
}
/// <summary>
/// School-side queue of pausing notices. Ceiling is <see cref="Simulation.SimulationOptions.MaxStickyNotices"/>.
/// The ninth is not posted. Dismiss does not start the clock.
/// </summary>
internal sealed class NoticeBoard
{
private readonly List<StickyNotice> _sticky = [];
private uint _nextId;
public NoticeBoard(int maxSticky, IReadOnlyList<StickyNoticeSave>? 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<StickyNotice> Sticky => _sticky;
public IReadOnlyList<StickyNoticeSave> ToSave() => [.. _sticky.Select(notice => notice.ToSave())];
/// <summary>
/// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast
/// only. Returns <see langword="false"/> when the sticky ceiling would be exceeded.
/// </summary>
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;
}
}
+21
View File
@@ -46,6 +46,25 @@ internal sealed class SchoolSave
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary> /// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
public SwarmUiConfigFile? PortraitSettings { get; init; } public SwarmUiConfigFile? PortraitSettings { get; init; }
/// <summary>Unclosed pausing notices. Info is never written here.</summary>
public IReadOnlyList<StickyNoticeSave>? Notices { get; init; }
}
/// <summary>One sticky warning/error kept across F5 and worker restart.</summary>
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; }
} }
/// <summary>Allocates school ids that survive a process restart.</summary> /// <summary>Allocates school ids that survive a process restart.</summary>
@@ -193,6 +212,7 @@ internal sealed class SchoolStore
SpeechRules = save.SpeechRules, SpeechRules = save.SpeechRules,
Owner = save.Owner, Owner = save.Owner,
PortraitSettings = save.PortraitSettings, PortraitSettings = save.PortraitSettings,
Notices = save.Notices,
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -242,6 +262,7 @@ internal sealed class SchoolStore
SpeechRules = save.SpeechRules, SpeechRules = save.SpeechRules,
Owner = save.Owner, Owner = save.Owner,
PortraitSettings = save.PortraitSettings, PortraitSettings = save.PortraitSettings,
Notices = save.Notices,
}; };
} }
@@ -1,7 +1,9 @@
using HSchool.Content; using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule; using HSchool.Schedule;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation; using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
@@ -36,6 +38,7 @@ internal sealed partial class SchoolWorker
SendMapSnapshot(open.Client, school); SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school); BroadcastClockTo(open.Client, school);
SendPresence(open.Client, school); SendPresence(open.Client, school);
SendStickyNotices(open.Client);
break; break;
case WorkerCommand.Close close: case WorkerCommand.Close close:
@@ -48,6 +51,11 @@ internal sealed partial class SchoolWorker
break; break;
case WorkerCommand.SetRunning setRunning: case WorkerCommand.SetRunning setRunning:
if (setRunning.Running && _notices.HasPausing)
{
break;
}
school.Clock.IsRunning = setRunning.Running; school.Clock.IsRunning = setRunning.Running;
dirty = true; dirty = true;
break; break;
@@ -61,8 +69,36 @@ internal sealed partial class SchoolWorker
ApplySkip(school); ApplySkip(school);
break; break;
case WorkerCommand.DismissNotice: case WorkerCommand.DismissNotice dismiss:
// Info is not stored; pausing dismiss is phase 65. 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; break;
case WorkerCommand.Dump dump: case WorkerCommand.Dump dump:
@@ -184,6 +220,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.Dump dump: case WorkerCommand.Dump dump:
dump.Result.TrySetResult(null); dump.Result.TrySetResult(null);
break; break;
case WorkerCommand.PostNotice post:
post.Result.TrySetResult(null);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetResult();
break;
case WorkerCommand.GetPerson getPerson: case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break; break;
@@ -227,6 +269,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.Dump dump: case WorkerCommand.Dump dump:
dump.Result.TrySetException(exception); dump.Result.TrySetException(exception);
break; break;
case WorkerCommand.PostNotice post:
post.Result.TrySetException(exception);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetException(exception);
break;
case WorkerCommand.GetPerson getPerson: case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception); getPerson.Result.TrySetException(exception);
break; break;
@@ -380,4 +428,34 @@ internal sealed partial class SchoolWorker
ApplyTable(school, next, broadcast: true); ApplyTable(school, next, broadcast: true);
return TimetableOutcome.Ok(next); 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));
}
}
} }
@@ -210,6 +210,7 @@ internal sealed partial class SchoolWorker
SpeechRules = school.SpeechRules, SpeechRules = school.SpeechRules,
Owner = _owner, Owner = _owner,
PortraitSettings = _portraitSettings, PortraitSettings = _portraitSettings,
Notices = _notices.ToSave(),
}); });
} }
catch (Exception ex) catch (Exception ex)
+7 -9
View File
@@ -95,6 +95,11 @@ internal sealed partial class SchoolWorker
_school = school; _school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules(); school.DressRules = _savedDressRules ?? new SchoolDressRules();
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules(); school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
if (_notices.HasPausing)
{
school.Clock.IsRunning = false;
}
PublishSnapshot(); PublishSnapshot();
if (_isNew) if (_isNew)
@@ -505,19 +510,12 @@ internal sealed partial class SchoolWorker
{ {
foreach (var def in catalog.Events.Values) foreach (var def in catalog.Events.Values)
{ {
if (def.Abstract if (def.Abstract || !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal))
|| !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal)
|| !def.Severity.Equals(EventSeverities.Info, StringComparison.Ordinal))
{ {
continue; continue;
} }
BroadcastNotice(new ServerNoticeMessage( TryEmitNotice(school, def, personId: 0);
++_nextNoticeId,
def.DefName,
NoticeSeverity.Info,
def.Pause,
(uint)Math.Max(0, def.TtlMs)));
} }
} }
} }
+4 -2
View File
@@ -53,7 +53,7 @@ internal sealed partial class SchoolWorker
private Timetable? _timetableSnapshot; private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot; private MapLayout? _mapSnapshot;
private int _presenceAge; private int _presenceAge;
private uint _nextNoticeId; private readonly NoticeBoard _notices;
private School? _school; private School? _school;
private Task? _run; private Task? _run;
private bool _persistOnStop = true; private bool _persistOnStop = true;
@@ -78,6 +78,7 @@ internal sealed partial class SchoolWorker
SchoolSpeechRules? savedSpeechRules, SchoolSpeechRules? savedSpeechRules,
string? owner, string? owner,
SwarmUiConfigFile? portraitSettings, SwarmUiConfigFile? portraitSettings,
IReadOnlyList<StickyNoticeSave>? savedNotices,
SimulationOptions options, SimulationOptions options,
ClientRegistry clients, ClientRegistry clients,
GameMetrics metrics, GameMetrics metrics,
@@ -110,7 +111,8 @@ internal sealed partial class SchoolWorker
_mods = mods; _mods = mods;
_onFailed = onFailed; _onFailed = onFailed;
_logger = logger; _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; public int Id => _id;
+10
View File
@@ -24,6 +24,14 @@ internal abstract record WorkerCommand
internal sealed record DismissNotice(uint Id) : WorkerCommand; internal sealed record DismissNotice(uint Id) : WorkerCommand;
internal sealed record DismissNoticeHttp(uint Id, TaskCompletionSource Result) : WorkerCommand;
/// <summary>Test/dev post onto the school board. Looks up an EventDef by name.</summary>
internal sealed record PostNotice(
string DefName,
uint PersonId,
TaskCompletionSource<PostedNotice?> Result) : WorkerCommand;
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand; internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
internal sealed record GetPerson( internal sealed record GetPerson(
@@ -81,3 +89,5 @@ internal abstract record WorkerCommand
string? PendingStaff, string? PendingStaff,
TaskCompletionSource<SpeechRulesOutcome> Result) : WorkerCommand; TaskCompletionSource<SpeechRulesOutcome> Result) : WorkerCommand;
} }
internal sealed record PostedNotice(uint Id, string DefName, bool Pause);
+5 -1
View File
@@ -182,7 +182,11 @@ internal sealed class GameSocketHandler(
case MessageType.ClientDismissNotice: case MessageType.ClientDismissNotice:
var dismiss = ProtocolCodec.ReadDismissNotice(frame); 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; break;
default: default:
+1
View File
@@ -37,6 +37,7 @@ builder.Services
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.") .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.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.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(); .ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>(); builder.Services.AddSingleton<GameCommandQueue>();
@@ -64,6 +64,11 @@ public sealed class SimulationOptions
/// </summary> /// </summary>
public int MaxDecisionsPerTick { get; set; } = 64; public int MaxDecisionsPerTick { get; set; } = 64;
/// <summary>
/// Unclosed warning/error notices kept in the save. The next one is not posted.
/// </summary>
public int MaxStickyNotices { get; set; } = 8;
/// <summary> /// <summary>
/// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day. /// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day.
/// </summary> /// </summary>
@@ -121,6 +121,177 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Null(replayed); 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] [Fact]
public async Task OpeningASchool_SendsAMapSnapshot() public async Task OpeningASchool_SendsAMapSnapshot()
{ {
@@ -539,6 +710,18 @@ public class GameSocketTests(AppHostFixture fixture)
private static DateTime ToDate(ServerClockMessage clock) => private static DateTime ToDate(ServerClockMessage clock) =>
DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime; DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime;
private static async Task<DevNoticeResponse> 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<DevNoticeResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
return body;
}
private async Task<SchoolApiTests.SchoolResponse> FindAsync(HttpClient client, int schoolId) private async Task<SchoolApiTests.SchoolResponse> FindAsync(HttpClient client, int schoolId)
{ {
var state = await SchoolApiTests.GetSchoolsAsync(client); var state = await SchoolApiTests.GetSchoolsAsync(client);
@@ -784,4 +967,6 @@ public class GameSocketTests(AppHostFixture fixture)
private sealed record DirectorySnapshot(IReadOnlyList<DirectoryPersonSnapshot> People); private sealed record DirectorySnapshot(IReadOnlyList<DirectoryPersonSnapshot> People);
private sealed record DirectoryPersonSnapshot(string Id, string FullName); private sealed record DirectoryPersonSnapshot(string Id, string FullName);
private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause);
} }
@@ -26,7 +26,11 @@ public class EventDefTests
Assert.Equal(EventActions.None, catalog.Events["LessonStarted"].Action); Assert.Equal(EventActions.None, catalog.Events["LessonStarted"].Action);
Assert.True(catalog.Events.ContainsKey("GenerationFailed")); 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(EventTriggers.GenerationFailed, catalog.Events["GenerationFailed"].Trigger);
Assert.Equal(EventActions.None, catalog.Events["GenerationFailed"].Action);
Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"])); Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"]));
Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"])); Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"]));
} }
@@ -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,
};
}
@@ -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) private static SchoolStore CreateStore(string directory)
{ {
var options = Options.Create(new SimulationOptions { SavesDirectory = directory }); var options = Options.Create(new SimulationOptions { SavesDirectory = directory });