Merge branch 'phase/65-warning-pause'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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, часы на паузе
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
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
|
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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 Mon–Fri; six adds Saturday; seven is every day.
|
/// Working days from Monday. Five is Mon–Fri; 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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user