Retry a failed portrait from the notice with the same scene builder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 01:48:30 +03:00
co-authored by Cursor
parent 3c70227229
commit 3061e09d3e
32 changed files with 712 additions and 105 deletions
+11 -11
View File
@@ -13,26 +13,26 @@
## Задачи ## Задачи
- [ ] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice` - [x] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice`
не рисовать, не 500 не рисовать, не 500
- [ ] Ваниль `GenerationFailed`: error, pause, `generateImage`. Эмит из сервиса портретов при - [x] Ваниль `GenerationFailed`: error, pause, `generateImage`. Эмит из сервиса портретов при
неуспехе Swarm (тот же personId и kind). Утро и звонок остаются `none` неуспехе Swarm (тот же personId и kind). Утро и звонок остаются `none`
- [ ] UI: на модале/тосте с action — кнопка; вызов существующего портретного HTTP (или узкого - [x] UI: на модале/тосте с action — кнопка; вызов существующего портретного HTTP (или узкого
`POST .../notices/{id}/generate`, если так проще связать права). Kind по умолчанию `full`. `POST .../notices/{id}/generate`, если так проще связать права). Kind по умолчанию `full`.
Строки через `t(...)` Строки через `t(...)`
- [ ] Хозяин только. Гость кнопку не активирует. Успех не снимает паузу сам — нужен dismiss - [x] Хозяин только. Гость кнопку не активирует. Успех не снимает паузу сам — нужен dismiss
- [ ] [`protocol.md`](../../protocol.md) — HTTP действия, тем же коммитом. Сокет не бампить - [x] [`protocol.md`](../../protocol.md) — HTTP действия, тем же коммитом. Сокет не бампить
повторно, если кадр 64 уже несёт action/personId; иначе добавить поля в том же смысле, повторно, если кадр 64 уже несёт action/personId; иначе добавить поля в том же смысле,
что 64, без лишнего +1 если укладка уже есть что 64, без лишнего +1 если укладка уже есть
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Каталог: `GenerationFailed` с `generateImage`; `DayStarted` без кнопки - [x] Каталог: `GenerationFailed` с `generateImage`; `DayStarted` без кнопки
- [ ] Неуспех генерации ставит pausing notice с personId; успех — не ставит - [x] Неуспех генерации ставит pausing notice с personId; успех — не ставит
- [ ] Хостовый: хозяин с кнопки получает PNG (или тот же outcome, что карточка); гость 403 - [x] Хостовый: хозяин с кнопки получает PNG (или тот же outcome, что карточка); гость 403
- [ ] Промпт этой генерации содержит сцену узла человека (как 66), не пустой коридор без людей - [x] Промпт этой генерации содержит сцену узла человека (как 66), не пустой коридор без людей
- [ ] Notice без personId и с `generateImage` не дергает Swarm - [x] Notice без personId и с `generateImage` не дергает Swarm
- [ ] Клиентский: кнопка есть только при action; info-тост дня кнопки не имеет - [x] Клиентский: кнопка есть только при action; info-тост дня кнопки не имеет
## Критерий готовности ## Критерий готовности
+22 -4
View File
@@ -814,6 +814,20 @@ Owner-only close of a pausing notice. `204` even when that id is already gone. G
sticky warning/error. Socket dismiss (`0x08`) is the live intent; this HTTP route is the 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. same right check for clients that are not on the socket.
### `POST /api/schools/{id}/notices/{noticeId}/generate`
Owner-only portrait from a sticky notice whose `action` is `generateImage`. Uses the same
`GetPortraitBuildInput` assembly as the person card (scene included). Kind is the one stored
on the notice when Swarm failed, or `full`. Success is the same `201` body as
`POST /api/schools/{id}/people/{personId}/portrait`; Swarm down is the same `503` / `502` /
`504`. The notice stays on the board — a PNG does not dismiss it or start the clock.
| Status | `code` | When |
| --- | --- | --- |
| `400` | `notice-cannot-generate` | No person on the notice, or action is not `generateImage`. Swarm is not called. |
| `403` | `not-owner` | Guest. |
| `404` | `unknown-school` / `unknown-notice` | Missing school or that id is not a sticky notice. |
## 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
@@ -868,9 +882,11 @@ read the `World`. Unknown `{id}` is `404` `unknown-school`.
### `POST /api/dev/schools/{id}/notices` ### `POST /api/dev/schools/{id}/notices`
Posts a catalog `EventDef` onto that school's board without going through Swarm. Body: Posts a catalog `EventDef` onto that school's board without going through Swarm. Body:
`{ "defName": "GenerationFailed", "personId": 0 }`. Success is `{ "id", "defName", "pause" }`. `{ "defName": "GenerationFailed", "personId": 0, "person": "a0.p0", "kind": "full" }`.
`person` is the roster id to put on the notice (empty/omitted = nobody in frame). `kind` is
the portrait kind to retry (`avatar` / `custom` / `full`). Success is `{ "id", "defName", "pause" }`.
Unknown `{id}` is `404` `unknown-school`. Unknown def or a full sticky queue is `409` 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. `notice-rejected`. Tests use this; a live Swarm failure posts `GenerationFailed` itself.
## WebSocket message ids ## WebSocket message ids
@@ -1100,8 +1116,9 @@ on this frame; the client builds «говорит с Машей о футбол
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. Warning/error with `pause=1` Info is not written to the save and is not resent on OpenSchool. Warning/error with `pause=1`
stop the clock, stay in the save (at most eight), and are sent again on Open. The ninth sticky 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 notice is not emitted. `personId` is `0` when nobody is in frame. `action` is the catalog value
later phase). (`none` or `generateImage`); the generate button is owner-only HTTP, not a new socket id.
Layout extra after the v10 personId field; protocol version stays 10.
| Offset | Type | Field | | Offset | Type | Field |
| --- | --- | --- | | --- | --- | --- |
@@ -1112,6 +1129,7 @@ later phase).
| … | `u8` | `1` pause (clocks stop until dismiss + Play), `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 |
| … | string | `action`; `none` or `generateImage` |
## Guarantees and limits ## Guarantees and limits
+4
View File
@@ -387,6 +387,8 @@ const ru = {
LessonStarted: 'Начало урока', LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет', GenerationFailed: 'Не удалось нарисовать портрет',
noticeDismiss: 'Закрыть', noticeDismiss: 'Закрыть',
noticeGenerateImage: 'Создать картинку',
noticeGenerateImageBusy: 'Рисуем…',
noticePauseLocked: 'Закройте предупреждение, чтобы продолжить', noticePauseLocked: 'Закройте предупреждение, чтобы продолжить',
} as const; } as const;
@@ -779,6 +781,8 @@ const en: Messages = {
LessonStarted: 'A lesson has started', LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed', GenerationFailed: 'Portrait generation failed',
noticeDismiss: 'Close', noticeDismiss: 'Close',
noticeGenerateImage: 'Create image',
noticeGenerateImageBusy: 'Drawing…',
noticePauseLocked: 'Close the warning to resume', noticePauseLocked: 'Close the warning to resume',
}; };
+1
View File
@@ -44,6 +44,7 @@ async function bootstrap(): Promise<void> {
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex), onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
onSkip: () => connection.skipEmpty(), onSkip: () => connection.skipEmpty(),
onDismissNotice: (id) => connection.dismissNotice(id), onDismissNotice: (id) => connection.dismissNotice(id),
onGenerateNotice: (id) => void game.generateFromNotice(id),
}); });
const connection = new GameConnection(gameSocketUrl(), { const connection = new GameConnection(gameSocketUrl(), {
+4
View File
@@ -661,6 +661,10 @@ export async function generatePortrait(
); );
} }
export async function generateNoticePortrait(schoolId: number, noticeId: number): Promise<PortraitResult> {
return request<PortraitResult>(`/api/schools/${schoolId}/notices/${noticeId}/generate`, { method: 'POST' });
}
export interface DirectoryPerson { export interface DirectoryPerson {
readonly id: string; readonly id: string;
readonly fullName: string; readonly fullName: string;
+7 -1
View File
@@ -446,8 +446,10 @@ describe('decodeServerMessage', () => {
it('reads a notice frame by the documented offsets', () => { it('reads a notice frame by the documented offsets', () => {
const defName = 'DayStarted'; const defName = 'DayStarted';
const action = 'none';
const encoded = new TextEncoder().encode(defName); const encoded = new TextEncoder().encode(defName);
const buffer = new ArrayBuffer(1 + 4 + 2 + encoded.length + 1 + 1 + 4 + 4); const actionBytes = new TextEncoder().encode(action);
const buffer = new ArrayBuffer(1 + 4 + 2 + encoded.length + 1 + 1 + 4 + 4 + 2 + actionBytes.length);
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerNotice); view.setUint8(0, MessageType.ServerNotice);
view.setUint32(1, 0x0a0b0c0d, true); view.setUint32(1, 0x0a0b0c0d, true);
@@ -458,6 +460,9 @@ describe('decodeServerMessage', () => {
view.setUint8(afterName + 1, 0); view.setUint8(afterName + 1, 0);
view.setUint32(afterName + 2, 8000, true); view.setUint32(afterName + 2, 8000, true);
view.setUint32(afterName + 6, 0, true); view.setUint32(afterName + 6, 0, true);
const actionAt = afterName + 10;
view.setUint16(actionAt, actionBytes.length, true);
new Uint8Array(buffer).set(actionBytes, actionAt + 2);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'notice', type: 'notice',
@@ -467,6 +472,7 @@ describe('decodeServerMessage', () => {
pause: false, pause: false,
ttlMs: 8000, ttlMs: 8000,
personId: 0, personId: 0,
action: 'none',
}); });
}); });
+5
View File
@@ -150,6 +150,8 @@ export interface NoticeMessage {
readonly ttlMs: number; readonly ttlMs: number;
/** 0 when nobody is in frame. */ /** 0 when nobody is in frame. */
readonly personId: number; readonly personId: number;
/** Catalog action: `none` or `generateImage`. */
readonly action: string;
} }
export type ServerMessage = export type ServerMessage =
@@ -443,6 +445,8 @@ function decodeNotice(view: DataView): NoticeMessage {
const ttlMs = view.getUint32(offset, true); const ttlMs = view.getUint32(offset, true);
offset += 4; offset += 4;
const personId = view.getUint32(offset, true); const personId = view.getUint32(offset, true);
offset += 4;
const action = readString(view, offset);
return { return {
type: 'notice', type: 'notice',
@@ -452,6 +456,7 @@ function decodeNotice(view: DataView): NoticeMessage {
pause: pause !== 0, pause: pause !== 0,
ttlMs, ttlMs,
personId, personId,
action: action.text,
}; };
} }
+26
View File
@@ -1634,6 +1634,25 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
.notice-toast--actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
cursor: default;
}
.notice-toast__text {
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.notice-modals { .notice-modals {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1672,3 +1691,10 @@ body {
font-size: 15px; font-size: 15px;
} }
.notice-modal__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
+23 -2
View File
@@ -12,7 +12,7 @@ import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeek
import { formatWeather } from '../format/weather.ts'; import { formatWeather } from '../format/weather.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { fetchDirectory, type School } from '../net/api.ts'; import { fetchDirectory, generateNoticePortrait, 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';
@@ -37,6 +37,7 @@ interface GameScreenOptions {
readonly onSetSpeed: (speedIndex: number) => void; readonly onSetSpeed: (speedIndex: number) => void;
readonly onSkip: () => void; readonly onSkip: () => void;
readonly onDismissNotice?: (id: number) => void; readonly onDismissNotice?: (id: number) => void;
readonly onGenerateNotice?: (id: number) => void;
} }
const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10']; const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10'];
@@ -118,11 +119,17 @@ export class GameScreen {
private leftTab: LeftTab = 'map'; private leftTab: LeftTab = 'map';
constructor(options: GameScreenOptions) { constructor(options: GameScreenOptions) {
this.notices = new NoticeToasts((id) => options.onDismissNotice?.(id)); this.notices = new NoticeToasts(
(id) => options.onDismissNotice?.(id),
(id) => options.onGenerateNotice?.(id),
() => this.canManage,
);
this.noticeModals = new NoticeModals( this.noticeModals = new NoticeModals(
(id) => options.onDismissNotice?.(id), (id) => options.onDismissNotice?.(id),
() => this.canManage, () => this.canManage,
() => this.paintPlay(), () => this.paintPlay(),
(id) => options.onGenerateNotice?.(id),
() => this.canManage,
); );
this.speedButtons = CLOCK_SPEEDS.map((_, index) => this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', { el('button', {
@@ -334,6 +341,20 @@ export class GameScreen {
this.notices.show(message); this.notices.show(message);
} }
async generateFromNotice(id: number): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null || !this.canManage) {
return;
}
this.noticeModals.setGenerating(id);
try {
await generateNoticePortrait(schoolId, id);
} finally {
this.noticeModals.setGenerating(null);
}
}
/** Names for the presence stream. The frame itself never carries display names. */ /** Names for the presence stream. The frame itself never carries display names. */
applyDirectory(people: readonly { id: string; fullName: string }[]): void { applyDirectory(people: readonly { id: string; fullName: string }[]): void {
this.directory = new Map(people.map((person) => [person.id, person.fullName])); this.directory = new Map(people.map((person) => [person.id, person.fullName]));
@@ -21,6 +21,7 @@ function pausing(id: number, defName = 'GenerationFailed'): Parameters<NoticeMod
pause: true, pause: true,
ttlMs: 8000, ttlMs: 8000,
personId: 0, personId: 0,
action: 'none',
}; };
} }
@@ -66,4 +67,28 @@ describe('NoticeModals', () => {
expect(modals.element.querySelector('button')).toBeNull(); expect(modals.element.querySelector('button')).toBeNull();
expect(modals.element.querySelector('.notice-modal')).not.toBeNull(); expect(modals.element.querySelector('.notice-modal')).not.toBeNull();
}); });
it('shows a generate button only when the notice has the action and the viewer can generate', () => {
const onGenerate = vi.fn();
const withAction = new NoticeModals(() => {}, () => true, () => {}, onGenerate, () => true);
document.body.append(withAction.element);
withAction.show({ ...pausing(1), personId: 1, action: 'generateImage' });
expect(withAction.element.querySelector('[data-notice-action="generate"]')?.textContent).toBe(
t('noticeGenerateImage'),
);
withAction.element
.querySelector('[data-notice-action="generate"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onGenerate).toHaveBeenCalledWith(1);
const guest = new NoticeModals(() => {}, () => false, () => {}, onGenerate, () => false);
document.body.append(guest.element);
guest.show({ ...pausing(2), personId: 1, action: 'generateImage' });
expect(guest.element.querySelector('[data-notice-action="generate"]')).toBeNull();
const noAction = new NoticeModals(() => {}, () => true, () => {}, onGenerate, () => true);
document.body.append(noAction.element);
noAction.show(pausing(3, 'GenerationFailed'));
expect(noAction.element.querySelector('[data-notice-action="generate"]')).toBeNull();
});
}); });
+39 -7
View File
@@ -1,20 +1,24 @@
import type { NoticeMessage } from '../net/protocol.ts'; import type { NoticeMessage } from '../net/protocol.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { noticeLabel } from './noticeToasts.ts'; import { noticeHasGenerate, noticeLabel } from './noticeToasts.ts';
/** /**
* One pausing warning/error at a time. Not a toast: no TTL, a queue, close is dismiss. * 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. * Guests see the card and have no close or generate button — only the owner can clear the pause
* or retry the portrait.
*/ */
export class NoticeModals { export class NoticeModals {
readonly element = el('div', { class: 'notice-modals', hidden: true }); readonly element = el('div', { class: 'notice-modals', hidden: true });
private readonly queue: NoticeMessage[] = []; private readonly queue: NoticeMessage[] = [];
private generatingId: number | null = null;
constructor( constructor(
private readonly onDismiss: (id: number) => void, private readonly onDismiss: (id: number) => void,
private readonly canDismiss: () => boolean, private readonly canDismiss: () => boolean,
private readonly onChange: () => void, private readonly onChange: () => void,
private readonly onGenerate?: (id: number) => void,
private readonly canGenerate: () => boolean = () => false,
) {} ) {}
get open(): boolean { get open(): boolean {
@@ -37,6 +41,7 @@ export class NoticeModals {
clear(): void { clear(): void {
this.queue.length = 0; this.queue.length = 0;
this.generatingId = null;
this.paint(); this.paint();
this.onChange(); this.onChange();
} }
@@ -45,12 +50,21 @@ export class NoticeModals {
this.paint(); this.paint();
} }
setGenerating(id: number | null): void {
this.generatingId = id;
this.paint();
}
private dismissCurrent(): void { private dismissCurrent(): void {
const current = this.queue.shift(); const current = this.queue.shift();
if (current === undefined) { if (current === undefined) {
return; return;
} }
if (this.generatingId === current.id) {
this.generatingId = null;
}
this.onDismiss(current.id); this.onDismiss(current.id);
this.paint(); this.paint();
this.onChange(); this.onChange();
@@ -65,14 +79,32 @@ export class NoticeModals {
} }
this.element.hidden = false; this.element.hidden = false;
const close = this.canDismiss() const actions = el('div', { class: 'notice-modal__actions' });
? el('button', { if (this.canGenerate() && noticeHasGenerate(current) && this.onGenerate !== undefined) {
const busy = this.generatingId === current.id;
actions.append(
el('button', {
class: 'button',
type: 'button',
text: t((busy ? 'noticeGenerateImageBusy' : 'noticeGenerateImage') satisfies MessageKey),
disabled: busy,
dataset: { noticeAction: 'generate' },
onClick: () => this.onGenerate?.(current.id),
}),
);
}
if (this.canDismiss()) {
actions.append(
el('button', {
class: 'button', class: 'button',
type: 'button', type: 'button',
text: t('noticeDismiss' satisfies MessageKey), text: t('noticeDismiss' satisfies MessageKey),
onClick: () => this.dismissCurrent(), onClick: () => this.dismissCurrent(),
}) }),
: null; );
}
const card = el( const card = el(
'div', 'div',
{ {
@@ -80,7 +112,7 @@ export class NoticeModals {
dataset: { noticeId: String(current.id), noticeDef: current.defName }, dataset: { noticeId: String(current.id), noticeDef: current.defName },
}, },
el('p', { class: 'notice-modal__text', text: noticeLabel(current.defName) }), el('p', { class: 'notice-modal__text', text: noticeLabel(current.defName) }),
close, actions.childElementCount > 0 ? actions : null,
); );
this.element.replaceChildren(card); this.element.replaceChildren(card);
} }
+31 -18
View File
@@ -12,6 +12,19 @@ afterEach(() => {
setLocale('ru'); setLocale('ru');
}); });
function info(id: number, defName: string, action = 'none', personId = 0): Parameters<NoticeToasts['show']>[0] {
return {
type: 'notice',
id,
defName,
severity: 0,
pause: false,
ttlMs: 8000,
personId,
action,
};
}
describe('NoticeToasts', () => { describe('NoticeToasts', () => {
it('shows the localized defName and click sends dismiss', () => { it('shows the localized defName and click sends dismiss', () => {
setLocale('ru'); setLocale('ru');
@@ -19,18 +32,11 @@ describe('NoticeToasts', () => {
const toasts = new NoticeToasts(onDismiss); const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element); document.body.append(toasts.element);
toasts.show({ toasts.show(info(7, 'DayStarted'));
type: 'notice',
id: 7,
defName: 'DayStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
const toast = toasts.element.querySelector('.notice-toast'); const toast = toasts.element.querySelector('.notice-toast');
expect(toast?.textContent).toBe(t('DayStarted')); expect(toast?.textContent).toBe(t('DayStarted'));
expect(toast?.querySelector('[data-notice-action="generate"]')).toBeNull();
expect(document.querySelector('.events-column')).toBeNull(); expect(document.querySelector('.events-column')).toBeNull();
expect(document.querySelector('[data-events-column]')).toBeNull(); expect(document.querySelector('[data-events-column]')).toBeNull();
@@ -45,18 +51,25 @@ describe('NoticeToasts', () => {
const toasts = new NoticeToasts(onDismiss); const toasts = new NoticeToasts(onDismiss);
document.body.append(toasts.element); document.body.append(toasts.element);
toasts.show({ toasts.show(info(3, 'LessonStarted'));
type: 'notice',
id: 3,
defName: 'LessonStarted',
severity: 0,
pause: false,
ttlMs: 8000,
personId: 0,
});
vi.advanceTimersByTime(8000); vi.advanceTimersByTime(8000);
expect(onDismiss).not.toHaveBeenCalled(); expect(onDismiss).not.toHaveBeenCalled();
expect(toasts.element.querySelector('.notice-toast')).toBeNull(); expect(toasts.element.querySelector('.notice-toast')).toBeNull();
}); });
it('shows a generate button only when the notice has the action', () => {
const onGenerate = vi.fn();
const toasts = new NoticeToasts(() => {}, onGenerate, () => true);
document.body.append(toasts.element);
toasts.show(info(1, 'DayStarted'));
expect(toasts.element.querySelector('[data-notice-action="generate"]')).toBeNull();
toasts.show(info(2, 'GenerationFailed', 'generateImage', 4));
const generate = toasts.element.querySelector('[data-notice-action="generate"]');
expect(generate?.textContent).toBe(t('noticeGenerateImage'));
generate?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onGenerate).toHaveBeenCalledWith(2);
});
}); });
+53 -15
View File
@@ -15,15 +15,24 @@ export function noticeLabel(defName: string): string {
} }
} }
export function noticeHasGenerate(notice: NoticeMessage): boolean {
return notice.action === 'generateImage' && notice.personId !== 0;
}
/** /**
* Info toasts over the school screen. Not a column: a stack in the corner that TTL and click * Info toasts over the school screen. Not a column: a stack in the corner that TTL and click
* dismiss. Click tells the server; TTL only hides locally. * dismiss. Click tells the server; TTL only hides locally. A generateImage action adds a button
* the owner can press; the day-start toast has none.
*/ */
export class NoticeToasts { export class NoticeToasts {
readonly element = el('div', { class: 'notice-toasts' }); readonly element = el('div', { class: 'notice-toasts' });
private readonly timers = new Map<number, ReturnType<typeof setTimeout>>(); private readonly timers = new Map<number, ReturnType<typeof setTimeout>>();
constructor(private readonly onDismiss: (id: number) => void) {} constructor(
private readonly onDismiss: (id: number) => void,
private readonly onGenerate?: (id: number) => void,
private readonly canGenerate: () => boolean = () => false,
) {}
show(notice: NoticeMessage): void { show(notice: NoticeMessage): void {
this.remove(notice.id); this.remove(notice.id);
@@ -37,17 +46,7 @@ export class NoticeToasts {
this.remove(Number.isFinite(oldestId) ? oldestId : 0, false); this.remove(Number.isFinite(oldestId) ? oldestId : 0, false);
} }
const toast = el( this.element.append(this.paintToast(notice));
'button',
{
class: 'notice-toast',
type: 'button',
text: noticeLabel(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
onClick: () => this.remove(notice.id, true),
},
);
this.element.append(toast);
if (notice.ttlMs > 0) { if (notice.ttlMs > 0) {
this.timers.set( this.timers.set(
@@ -66,14 +65,53 @@ export class NoticeToasts {
} }
localize(): void { localize(): void {
for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-def]')) { for (const node of this.element.querySelectorAll<HTMLElement>('[data-notice-label]')) {
const defName = node.dataset.noticeDef; const defName = node.dataset.noticeLabel;
if (defName !== undefined) { if (defName !== undefined) {
node.textContent = noticeLabel(defName); node.textContent = noticeLabel(defName);
} }
} }
} }
private paintToast(notice: NoticeMessage): HTMLElement {
const showGenerate =
this.canGenerate() && noticeHasGenerate(notice) && this.onGenerate !== undefined;
if (!showGenerate) {
return el('button', {
class: 'notice-toast',
type: 'button',
text: noticeLabel(notice.defName),
dataset: { noticeId: String(notice.id), noticeDef: notice.defName, noticeLabel: notice.defName },
onClick: () => this.remove(notice.id, true),
});
}
return el(
'div',
{
class: 'notice-toast notice-toast--actions',
dataset: { noticeId: String(notice.id), noticeDef: notice.defName },
},
el('button', {
class: 'notice-toast__text',
type: 'button',
text: noticeLabel(notice.defName),
dataset: { noticeLabel: notice.defName },
onClick: () => this.remove(notice.id, true),
}),
el('button', {
class: 'button button--small',
type: 'button',
text: t('noticeGenerateImage' satisfies MessageKey),
dataset: { noticeAction: 'generate' },
onClick: (event) => {
event.stopPropagation();
this.onGenerate?.(notice.id);
},
}),
);
}
private remove(id: number, send = false): void { private remove(id: number, send = false): void {
const timer = this.timers.get(id); const timer = this.timers.get(id);
if (timer !== undefined) { if (timer !== undefined) {
+9 -1
View File
@@ -95,6 +95,13 @@ public static class NoticeSeverity
public const byte Error = 2; public const byte Error = 2;
} }
/// <summary>Wire values for <see cref="ServerNoticeMessage.Action"/>.</summary>
public static class NoticeAction
{
public const string None = "none";
public const string GenerateImage = "generateImage";
}
/// <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. Pausing notices are saved and resent on Open. /// Info toasts are not replayed on OpenSchool. Pausing notices are saved and resent on Open.
@@ -105,7 +112,8 @@ public readonly record struct ServerNoticeMessage(
byte Severity, byte Severity,
bool Pause, bool Pause,
uint TtlMs, uint TtlMs,
uint PersonId = 0); uint PersonId = 0,
string Action = NoticeAction.None);
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary> /// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
public static class PresenceState public static class PresenceState
+4 -2
View File
@@ -280,7 +280,7 @@ public static class ProtocolCodec
} }
public static int NoticeSize(in ServerNoticeMessage message) => public static int NoticeSize(in ServerNoticeMessage message) =>
sizeof(byte) + sizeof(uint) + StringSize(message.DefName) + sizeof(byte) + sizeof(byte) + sizeof(uint) + sizeof(uint); sizeof(byte) + sizeof(uint) + StringSize(message.DefName) + sizeof(byte) + sizeof(byte) + sizeof(uint) + sizeof(uint) + StringSize(message.Action);
public static int WriteNotice(Span<byte> destination, in ServerNoticeMessage message) public static int WriteNotice(Span<byte> destination, in ServerNoticeMessage message)
{ {
@@ -292,6 +292,7 @@ public static class ProtocolCodec
writer.WriteByte(message.Pause ? (byte)1 : (byte)0); writer.WriteByte(message.Pause ? (byte)1 : (byte)0);
writer.WriteUInt32(message.TtlMs); writer.WriteUInt32(message.TtlMs);
writer.WriteUInt32(message.PersonId); writer.WriteUInt32(message.PersonId);
writer.WriteString(message.Action);
return writer.Position; return writer.Position;
} }
@@ -482,7 +483,8 @@ public static class ProtocolCodec
var pause = reader.ReadByte() != 0; var pause = reader.ReadByte() != 0;
var ttlMs = reader.ReadUInt32(); var ttlMs = reader.ReadUInt32();
var personId = reader.ReadUInt32(); var personId = reader.ReadUInt32();
return new ServerNoticeMessage(id, defName, severity, pause, ttlMs, personId); var action = reader.ReadString();
return new ServerNoticeMessage(id, defName, severity, pause, ttlMs, personId, action);
} }
private static bool HasPresenceActivity(PresenceNode node) => private static bool HasPresenceActivity(PresenceNode node) =>
+3 -2
View File
@@ -60,7 +60,8 @@ internal static class DevEndpoints
var command = new GameCommand.PostSchoolNotice( var command = new GameCommand.PostSchoolNotice(
id, id,
defName, defName,
request?.PersonId ?? 0, request?.Person?.Trim() ?? "",
request?.Kind?.Trim() ?? "",
NewCompletion<PostedNotice?>()); NewCompletion<PostedNotice?>());
commands.Enqueue(command); commands.Enqueue(command);
var posted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); var posted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
@@ -153,4 +154,4 @@ internal sealed record SchoolDumpLessonResponse(
int Day, int Day,
int Period); int Period);
internal sealed record PostDevNoticeRequest(string? DefName, uint PersonId); internal sealed record PostDevNoticeRequest(string? DefName, uint PersonId = 0, string? Person = null, string? Kind = null);
+85 -20
View File
@@ -396,26 +396,7 @@ internal static class SchoolEndpoints
} }
var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken); var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken);
return result.Outcome switch return PortraitGenerationHttp(id, personId, result);
{
PortraitGenerationOutcome.Succeeded => Results.Created(
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={PortraitKindParser.ToApiValue(portraitKind)}",
new PortraitResponse(
PortraitKindParser.ToApiValue(portraitKind),
result.HasAvatar,
result.HasCustom,
result.HasFullBody,
result.CustomPortraitPrompt)),
PortraitGenerationOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured =>
Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."),
PortraitGenerationOutcome.TimedOut =>
Problem(StatusCodes.Status504GatewayTimeout, "swarmui-timeout", "SwarmUI did not finish in time."),
_ => Problem(StatusCodes.Status502BadGateway, "swarmui-unavailable", "SwarmUI could not generate the portrait."),
};
}) })
.WithName("GenerateSchoolPersonPortrait"); .WithName("GenerateSchoolPersonPortrait");
@@ -735,6 +716,58 @@ internal static class SchoolEndpoints
return Results.NoContent(); return Results.NoContent();
}) })
.WithName("DismissSchoolNotice"); .WithName("DismissSchoolNotice");
schools.MapPost("/{id:int}/notices/{noticeId:long}/generate", async (
int id,
long noticeId,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
PortraitService portraits,
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 lookup = new GameCommand.GetNoticePortraitTarget(
id,
(uint)noticeId,
NewCompletion<NoticePortraitTarget>());
commands.Enqueue(lookup);
var target = await lookup.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return target.Error switch
{
NoticePortraitTargetError.UnknownSchool => Problem(
StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
NoticePortraitTargetError.UnknownNotice => Problem(
StatusCodes.Status404NotFound, "unknown-notice", "That notice is not on the board."),
NoticePortraitTargetError.CannotGenerate => Problem(
StatusCodes.Status400BadRequest,
"notice-cannot-generate",
"That notice has no person to draw."),
NoticePortraitTargetError.None => await GenerateNoticePortraitAsync(
id,
target,
portraits,
cancellationToken),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GenerateSchoolNoticePortrait");
} }
/// <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>
@@ -999,6 +1032,38 @@ internal static class SchoolEndpoints
return true; return true;
} }
private static async Task<IResult> GenerateNoticePortraitAsync(
int schoolId,
NoticePortraitTarget target,
PortraitService portraits,
CancellationToken cancellationToken)
{
var result = await portraits.GenerateAsync(schoolId, target.PersonId, target.Kind, promptExtra: null, cancellationToken);
return PortraitGenerationHttp(schoolId, target.PersonId, result);
}
private static IResult PortraitGenerationHttp(int schoolId, string personId, PortraitGenerationResult result) =>
result.Outcome switch
{
PortraitGenerationOutcome.Succeeded => Results.Created(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}/portrait?kind={PortraitKindParser.ToApiValue(result.Kind)}",
new PortraitResponse(
PortraitKindParser.ToApiValue(result.Kind),
result.HasAvatar,
result.HasCustom,
result.HasFullBody,
result.CustomPortraitPrompt)),
PortraitGenerationOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured =>
Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."),
PortraitGenerationOutcome.TimedOut =>
Problem(StatusCodes.Status504GatewayTimeout, "swarmui-timeout", "SwarmUI did not finish in time."),
_ => Problem(StatusCodes.Status502BadGateway, "swarmui-unavailable", "SwarmUI could not generate the portrait."),
};
private static IResult Problem( private static IResult Problem(
int statusCode, int statusCode,
string code, string code,
+7 -1
View File
@@ -49,9 +49,15 @@ internal abstract record GameCommand
internal sealed record PostSchoolNotice( internal sealed record PostSchoolNotice(
int SchoolId, int SchoolId,
string DefName, string DefName,
uint PersonId, string PersonKey,
string Kind,
TaskCompletionSource<PostedNotice?> Result) : GameCommand; TaskCompletionSource<PostedNotice?> Result) : GameCommand;
internal sealed record GetNoticePortraitTarget(
int SchoolId,
uint NoticeId,
TaskCompletionSource<NoticePortraitTarget> 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;
+14 -1
View File
@@ -237,6 +237,10 @@ internal sealed class GameLoopService(
HandlePostNotice(postNotice); HandlePostNotice(postNotice);
break; break;
case GameCommand.GetNoticePortraitTarget getTarget:
HandleGetNoticePortraitTarget(getTarget);
break;
case GameCommand.ReloadSaves reload: case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false); await HandleReloadAsync(reload).ConfigureAwait(false);
break; break;
@@ -690,12 +694,21 @@ internal sealed class GameLoopService(
private void HandlePostNotice(GameCommand.PostSchoolNotice command) private void HandlePostNotice(GameCommand.PostSchoolNotice command)
{ {
if (!_workers.TryGetValue(command.SchoolId, out var worker) if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonId, command.Result))) || !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonKey, command.Kind, command.Result)))
{ {
command.Result.TrySetResult(null); command.Result.TrySetResult(null);
} }
} }
private void HandleGetNoticePortraitTarget(GameCommand.GetNoticePortraitTarget command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.GetNoticePortraitTarget(command.NoticeId, command.Result)))
{
command.Result.TrySetResult(NoticePortraitTarget.UnknownSchool);
}
}
private async Task StartWorkersFromDiskAsync() private async Task StartWorkersFromDiskAsync()
{ {
var saves = store.LoadAll(); var saves = store.LoadAll();
@@ -0,0 +1,32 @@
namespace HSchool.Server.Game;
/// <summary>
/// Swarm failures raise <c>GenerationFailed</c> on the school's board. Success does not, and a
/// missing person id never posts.
/// </summary>
internal static class GenerationFailedPoster
{
public const string DefName = "GenerationFailed";
public static bool ShouldPost(PortraitGenerationOutcome outcome) =>
outcome is PortraitGenerationOutcome.Unavailable or PortraitGenerationOutcome.TimedOut;
public static void TryEnqueue(
GameCommandQueue commands,
int schoolId,
string personId,
PortraitKind kind)
{
if (string.IsNullOrWhiteSpace(personId))
{
return;
}
commands.Enqueue(new GameCommand.PostSchoolNotice(
schoolId,
DefName,
personId,
PortraitKindParser.ToApiValue(kind),
new TaskCompletionSource<PostedNotice?>(TaskCreationOptions.RunContinuationsAsynchronously)));
}
}
+28 -5
View File
@@ -18,7 +18,14 @@ internal sealed class StickyNotice
public required uint PersonId { get; init; } public required uint PersonId { get; init; }
public ServerNoticeMessage ToMessage() => new(Id, DefName, Severity, Pause, TtlMs, PersonId); public string PersonKey { get; init; } = "";
public string Kind { get; init; } = "";
public string Action { get; init; } = EventActions.None;
public ServerNoticeMessage ToMessage() =>
new(Id, DefName, Severity, Pause, TtlMs, PersonId, Action);
public StickyNoticeSave ToSave() => new() public StickyNoticeSave ToSave() => new()
{ {
@@ -28,6 +35,9 @@ internal sealed class StickyNotice
Pause = Pause, Pause = Pause,
TtlMs = TtlMs, TtlMs = TtlMs,
PersonId = PersonId, PersonId = PersonId,
PersonKey = PersonKey,
Kind = Kind,
Action = Action,
}; };
public static byte WireSeverity(string severity) => severity switch public static byte WireSeverity(string severity) => severity switch
@@ -36,6 +46,9 @@ internal sealed class StickyNotice
EventSeverities.Error => NoticeSeverity.Error, EventSeverities.Error => NoticeSeverity.Error,
_ => NoticeSeverity.Info, _ => NoticeSeverity.Info,
}; };
public static uint WirePersonId(string personKey) =>
string.IsNullOrWhiteSpace(personKey) ? 0u : 1u;
} }
/// <summary> /// <summary>
@@ -74,7 +87,10 @@ internal sealed class NoticeBoard
Severity = row.Severity, Severity = row.Severity,
Pause = true, Pause = true,
TtlMs = row.TtlMs, TtlMs = row.TtlMs,
PersonId = row.PersonId, PersonId = row.PersonId != 0 ? row.PersonId : StickyNotice.WirePersonId(row.PersonKey ?? ""),
PersonKey = row.PersonKey ?? "",
Kind = row.Kind ?? "",
Action = string.IsNullOrWhiteSpace(row.Action) ? EventActions.None : row.Action,
}); });
if (row.Id > _nextId) if (row.Id > _nextId)
{ {
@@ -91,11 +107,13 @@ internal sealed class NoticeBoard
public IReadOnlyList<StickyNoticeSave> ToSave() => [.. _sticky.Select(notice => notice.ToSave())]; public IReadOnlyList<StickyNoticeSave> ToSave() => [.. _sticky.Select(notice => notice.ToSave())];
public StickyNotice? Find(uint id) => _sticky.Find(notice => notice.Id == id);
/// <summary> /// <summary>
/// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast /// 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. /// only. Returns <see langword="false"/> when the sticky ceiling would be exceeded.
/// </summary> /// </summary>
public bool TryPost(EventDef def, uint personId, out ServerNoticeMessage message) public bool TryPost(EventDef def, string personKey, string kind, out ServerNoticeMessage message)
{ {
message = default; message = default;
if (def.Pause && _sticky.Count >= MaxSticky) if (def.Pause && _sticky.Count >= MaxSticky)
@@ -104,13 +122,15 @@ internal sealed class NoticeBoard
} }
var id = ++_nextId; var id = ++_nextId;
var wirePersonId = StickyNotice.WirePersonId(personKey);
message = new ServerNoticeMessage( message = new ServerNoticeMessage(
id, id,
def.DefName, def.DefName,
StickyNotice.WireSeverity(def.Severity), StickyNotice.WireSeverity(def.Severity),
def.Pause, def.Pause,
(uint)Math.Max(0, def.TtlMs), (uint)Math.Max(0, def.TtlMs),
personId); wirePersonId,
def.Action);
if (def.Pause) if (def.Pause)
{ {
_sticky.Add(new StickyNotice _sticky.Add(new StickyNotice
@@ -120,7 +140,10 @@ internal sealed class NoticeBoard
Severity = message.Severity, Severity = message.Severity,
Pause = true, Pause = true,
TtlMs = message.TtlMs, TtlMs = message.TtlMs,
PersonId = personId, PersonId = wirePersonId,
PersonKey = personKey ?? "",
Kind = kind ?? "",
Action = def.Action,
}); });
} }
@@ -156,16 +156,19 @@ internal sealed class PortraitService(
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{ {
logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId); logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId);
GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind);
return PortraitGenerationResult.TimedOut; return PortraitGenerationResult.TimedOut;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId); logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId);
GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind);
return PortraitGenerationResult.Unavailable; return PortraitGenerationResult.Unavailable;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId); logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId);
GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind);
return PortraitGenerationResult.Unavailable; return PortraitGenerationResult.Unavailable;
} }
} }
+6
View File
@@ -65,6 +65,12 @@ internal sealed class StickyNoticeSave
public uint TtlMs { get; init; } public uint TtlMs { get; init; }
public uint PersonId { get; init; } public uint PersonId { get; init; }
public string PersonKey { get; init; } = "";
public string Kind { get; init; } = "";
public string Action { get; init; } = "";
} }
/// <summary>Allocates school ids that survive a process restart.</summary> /// <summary>Allocates school ids that survive a process restart.</summary>
@@ -91,7 +91,7 @@ internal sealed partial class SchoolWorker
break; break;
} }
post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonId)); post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonKey, post.Kind));
break; break;
} }
@@ -101,6 +101,10 @@ internal sealed partial class SchoolWorker
dismissHttp.Result.TrySetResult(); dismissHttp.Result.TrySetResult();
break; break;
case WorkerCommand.GetNoticePortraitTarget getTarget:
getTarget.Result.TrySetResult(ReadNoticePortraitTarget(getTarget.NoticeId));
break;
case WorkerCommand.Dump dump: case WorkerCommand.Dump dump:
dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
break; break;
@@ -227,6 +231,9 @@ internal sealed partial class SchoolWorker
case WorkerCommand.PostNotice post: case WorkerCommand.PostNotice post:
post.Result.TrySetResult(null); post.Result.TrySetResult(null);
break; break;
case WorkerCommand.GetNoticePortraitTarget getTarget:
getTarget.Result.TrySetResult(NoticePortraitTarget.UnknownSchool);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp: case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetResult(); dismissHttp.Result.TrySetResult();
break; break;
@@ -280,6 +287,9 @@ internal sealed partial class SchoolWorker
case WorkerCommand.PostNotice post: case WorkerCommand.PostNotice post:
post.Result.TrySetException(exception); post.Result.TrySetException(exception);
break; break;
case WorkerCommand.GetNoticePortraitTarget getTarget:
getTarget.Result.TrySetException(exception);
break;
case WorkerCommand.DismissNoticeHttp dismissHttp: case WorkerCommand.DismissNoticeHttp dismissHttp:
dismissHttp.Result.TrySetException(exception); dismissHttp.Result.TrySetException(exception);
break; break;
@@ -454,9 +464,32 @@ internal sealed partial class SchoolWorker
return TimetableOutcome.Ok(next); return TimetableOutcome.Ok(next);
} }
private PostedNotice? TryEmitNotice(School school, EventDef def, uint personId) private NoticePortraitTarget ReadNoticePortraitTarget(uint noticeId)
{ {
if (!_notices.TryPost(def, personId, out var message)) var notice = _notices.Find(noticeId);
if (notice is null)
{
return NoticePortraitTarget.UnknownNotice;
}
if (!notice.Action.Equals(EventActions.GenerateImage, StringComparison.Ordinal)
|| string.IsNullOrWhiteSpace(notice.PersonKey))
{
return NoticePortraitTarget.CannotGenerate;
}
var kind = PortraitKind.Full;
if (!string.IsNullOrWhiteSpace(notice.Kind) && !PortraitKindParser.TryParse(notice.Kind, out kind))
{
kind = PortraitKind.Full;
}
return NoticePortraitTarget.Ok(notice.PersonKey, kind);
}
private PostedNotice? TryEmitNotice(School school, EventDef def, string personKey = "", string kind = "")
{
if (!_notices.TryPost(def, personKey, kind, out var message))
{ {
return null; return null;
} }
+1 -1
View File
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
continue; continue;
} }
TryEmitNotice(school, def, personId: 0); TryEmitNotice(school, def);
} }
} }
} }
+32 -1
View File
@@ -29,9 +29,14 @@ internal abstract record WorkerCommand
/// <summary>Test/dev post onto the school board. Looks up an EventDef by name.</summary> /// <summary>Test/dev post onto the school board. Looks up an EventDef by name.</summary>
internal sealed record PostNotice( internal sealed record PostNotice(
string DefName, string DefName,
uint PersonId, string PersonKey,
string Kind,
TaskCompletionSource<PostedNotice?> Result) : WorkerCommand; TaskCompletionSource<PostedNotice?> Result) : WorkerCommand;
internal sealed record GetNoticePortraitTarget(
uint NoticeId,
TaskCompletionSource<NoticePortraitTarget> 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(
@@ -96,3 +101,29 @@ internal abstract record WorkerCommand
} }
internal sealed record PostedNotice(uint Id, string DefName, bool Pause); internal sealed record PostedNotice(uint Id, string DefName, bool Pause);
internal enum NoticePortraitTargetError
{
None,
UnknownSchool,
UnknownNotice,
CannotGenerate,
}
internal sealed record NoticePortraitTarget(
NoticePortraitTargetError Error,
string PersonId,
PortraitKind Kind)
{
public static NoticePortraitTarget UnknownSchool { get; } =
new(NoticePortraitTargetError.UnknownSchool, "", PortraitKind.Full);
public static NoticePortraitTarget UnknownNotice { get; } =
new(NoticePortraitTargetError.UnknownNotice, "", PortraitKind.Full);
public static NoticePortraitTarget CannotGenerate { get; } =
new(NoticePortraitTargetError.CannotGenerate, "", PortraitKind.Full);
public static NoticePortraitTarget Ok(string personId, PortraitKind kind) =>
new(NoticePortraitTargetError.None, personId, kind);
}
@@ -21,6 +21,6 @@
"pause": true, "pause": true,
"ttlMs": 0, "ttlMs": 0,
"trigger": "generationFailed", "trigger": "generationFailed",
"action": "none", "action": "generateImage",
}, },
] ]
@@ -0,0 +1,122 @@
using System.Net;
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class NoticeImageApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task OwnerGenerate_MatchesCardOutcome_GuestIs403()
{
using var owner = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticeImageOwner");
await SchoolApiTests.WipeAllSavesAsync(owner);
var school = await SchoolApiTests.CreateAsync(owner, "Кнопка хозяина", Start);
var personId = await FirstPersonIdAsync(owner, school.Id);
var posted = await PostNoticeAsync(owner, school.Id, "GenerationFailed", personId, "full");
using var card = await owner.PostAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=full",
content: null,
TestContext.Current.CancellationToken);
using var fromNotice = await owner.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(card.StatusCode, fromNotice.StatusCode);
Assert.Equal(await SchoolApiTests.ProblemCodeAsync(card), await SchoolApiTests.ProblemCodeAsync(fromNotice));
var paused = (await SchoolApiTests.GetSchoolsAsync(owner)).Schools.Single(item => item.Id == school.Id);
Assert.False(paused.Running);
using var guest = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticeImageGuest");
using var forbidden = await guest.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(forbidden));
var stillPaused = (await SchoolApiTests.GetSchoolsAsync(owner)).Schools.Single(item => item.Id == school.Id);
Assert.False(stillPaused.Running);
}
[Fact]
public async Task GenerateWithoutPerson_DoesNotCallSwarm()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Без человека", Start);
var posted = await PostNoticeAsync(client, school.Id, "GenerationFailed");
using var response = await client.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var code = await SchoolApiTests.ProblemCodeAsync(response);
Assert.Equal("notice-cannot-generate", code);
Assert.NotEqual("swarmui-not-configured", code);
}
[Fact]
public async Task NoticePersonPrompt_ContainsThePersonNotAnEmptyCorridor()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Сцена с человеком", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
await PostNoticeAsync(client, school.Id, "GenerationFailed", personId, "full");
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait/prompt?kind=full",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<PortraitPromptPayload>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains("years old", payload!.Positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("wearing", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotMatch(@"\bage \d+", payload.Positive);
Assert.DoesNotContain("empty corridor", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
}
private static async Task<string> FirstPersonIdAsync(HttpClient client, int schoolId)
{
var page = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?pageSize=1",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
Assert.NotEmpty(page!.People);
return page.People[0].Id;
}
private static async Task<DevNoticeResponse> PostNoticeAsync(
HttpClient client,
int schoolId,
string defName,
string? person = null,
string? kind = null)
{
using var response = await client.PostAsJsonAsync(
$"/api/dev/schools/{schoolId}/notices",
new { defName, person, kind },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<DevNoticeResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
return body;
}
private sealed record PeopleListResponse(int Total, IReadOnlyList<PersonListItem> People);
private sealed record PersonListItem(string Id);
private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause);
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative);
}
+1 -1
View File
@@ -30,7 +30,7 @@ public class EventDefTests
Assert.True(catalog.Events["GenerationFailed"].Pause); Assert.True(catalog.Events["GenerationFailed"].Pause);
Assert.Equal(0, catalog.Events["GenerationFailed"].TtlMs); 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(EventActions.GenerateImage, 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"]));
} }
@@ -113,7 +113,7 @@ public class ProtocolCodecTests
[Fact] [Fact]
public void Notice_RoundTripsAndMatchesByteLayout() public void Notice_RoundTripsAndMatchesByteLayout()
{ {
var message = new ServerNoticeMessage(0x0A0B0C0D, "DayStarted", NoticeSeverity.Info, Pause: false, TtlMs: 8000, PersonId: 0); var message = new ServerNoticeMessage(0x0A0B0C0D, "DayStarted", NoticeSeverity.Info, Pause: false, TtlMs: 8000, PersonId: 0, Action: NoticeAction.None);
var size = ProtocolCodec.NoticeSize(message); var size = ProtocolCodec.NoticeSize(message);
Span<byte> buffer = stackalloc byte[size]; Span<byte> buffer = stackalloc byte[size];
@@ -128,6 +128,8 @@ public class ProtocolCodecTests
Assert.Equal(0, buffer[18]); Assert.Equal(0, buffer[18]);
Assert.Equal(8000u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[19..23])); Assert.Equal(8000u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[19..23]));
Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[23..27])); Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[23..27]));
Assert.Equal((ushort)4, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer[27..29]));
Assert.Equal("none"u8.ToArray(), buffer[29..33].ToArray());
Assert.Equal(message, ProtocolCodec.ReadNotice(buffer[..length])); Assert.Equal(message, ProtocolCodec.ReadNotice(buffer[..length]));
} }
@@ -0,0 +1,43 @@
using HSchool.Server.Game;
namespace HSchool.Server.Tests;
public class GenerationFailedPosterTests
{
[Fact]
public void SwarmFailure_ShouldPost_SuccessAndNotConfiguredShouldNot()
{
Assert.True(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.Unavailable));
Assert.True(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.TimedOut));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.Succeeded));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.NotConfigured));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.InvalidPrompt));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.UnknownPerson));
}
[Fact]
public async Task TryEnqueue_WithPerson_PostsGenerationFailed()
{
var queue = new GameCommandQueue();
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: "a0.p0", PortraitKind.Avatar);
var command = await queue.Reader.ReadAsync(TestContext.Current.CancellationToken);
var post = Assert.IsType<GameCommand.PostSchoolNotice>(command);
Assert.Equal(4, post.SchoolId);
Assert.Equal(GenerationFailedPoster.DefName, post.DefName);
Assert.Equal("a0.p0", post.PersonKey);
Assert.Equal("avatar", post.Kind);
}
[Fact]
public void TryEnqueue_WithoutPerson_DoesNotPost()
{
var queue = new GameCommandQueue();
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: "", PortraitKind.Full);
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: " ", PortraitKind.Full);
Assert.False(queue.Reader.TryRead(out _));
}
}
+31 -7
View File
@@ -11,12 +11,12 @@ public class NoticeBoardTests
{ {
var board = new NoticeBoard(maxSticky: 8); var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Info(), personId: 0, out var info)); Assert.True(board.TryPost(Info(), personKey: "", kind: "", out var info));
Assert.False(info.Pause); Assert.False(info.Pause);
Assert.False(board.HasPausing); Assert.False(board.HasPausing);
Assert.Empty(board.Sticky); Assert.Empty(board.Sticky);
Assert.True(board.TryPost(Pausing(), personId: 0, out var warning)); Assert.True(board.TryPost(Pausing(), personKey: "", kind: "", out var warning));
Assert.True(warning.Pause); Assert.True(warning.Pause);
Assert.True(board.HasPausing); Assert.True(board.HasPausing);
Assert.Equal("GenerationFailed", Assert.Single(board.Sticky).DefName); Assert.Equal("GenerationFailed", Assert.Single(board.Sticky).DefName);
@@ -29,10 +29,10 @@ public class NoticeBoardTests
var board = new NoticeBoard(maxSticky: 8); var board = new NoticeBoard(maxSticky: 8);
for (var i = 0; i < 8; i++) for (var i = 0; i < 8; i++)
{ {
Assert.True(board.TryPost(Pausing(), personId: 0, out _)); Assert.True(board.TryPost(Pausing(), personKey: "", kind: "", out _));
} }
Assert.False(board.TryPost(Pausing(), personId: 0, out var ninth)); Assert.False(board.TryPost(Pausing(), personKey: "", kind: "", out var ninth));
Assert.Equal(default, ninth); Assert.Equal(default, ninth);
Assert.Equal(8, board.Sticky.Count); Assert.Equal(8, board.Sticky.Count);
} }
@@ -41,8 +41,8 @@ public class NoticeBoardTests
public void Dismiss_RemovesSticky_DoesNotClearTheBoardUntilTheLast() public void Dismiss_RemovesSticky_DoesNotClearTheBoardUntilTheLast()
{ {
var board = new NoticeBoard(maxSticky: 8); var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Pausing(), personId: 0, out var first)); Assert.True(board.TryPost(Pausing(), personKey: "", kind: "", out var first));
Assert.True(board.TryPost(Pausing(), personId: 0, out var second)); Assert.True(board.TryPost(Pausing(), personKey: "", kind: "", out var second));
Assert.True(board.TryDismiss(first.Id)); Assert.True(board.TryDismiss(first.Id));
Assert.True(board.HasPausing); Assert.True(board.HasPausing);
@@ -71,6 +71,30 @@ public class NoticeBoardTests
Assert.Equal(NoticeSeverity.Error, sticky.Severity); Assert.Equal(NoticeSeverity.Error, sticky.Severity);
} }
[Fact]
public void GenerateImage_WithoutPerson_StillPosts_WirePersonIdIsZero()
{
var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Pausing(), personKey: "", kind: "full", out var message));
Assert.Equal(0u, message.PersonId);
Assert.Equal(EventActions.GenerateImage, message.Action);
Assert.Equal("", Assert.Single(board.Sticky).PersonKey);
}
[Fact]
public void GenerateImage_WithPerson_StoresKeyAndKind()
{
var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Pausing(), personKey: "a0.p0", kind: "avatar", out var message));
Assert.Equal(1u, message.PersonId);
Assert.Equal(EventActions.GenerateImage, message.Action);
var sticky = Assert.Single(board.Sticky);
Assert.Equal("a0.p0", sticky.PersonKey);
Assert.Equal("avatar", sticky.Kind);
}
private static EventDef Info() => new() private static EventDef Info() => new()
{ {
DefName = "DayStarted", DefName = "DayStarted",
@@ -88,6 +112,6 @@ public class NoticeBoardTests
Pause = true, Pause = true,
TtlMs = 0, TtlMs = 0,
Trigger = EventTriggers.GenerationFailed, Trigger = EventTriggers.GenerationFailed,
Action = EventActions.None, Action = EventActions.GenerateImage,
}; };
} }