From 3061e09d3e002041d42dff3e46de3a898bcdc659 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 01:48:30 +0300 Subject: [PATCH] Retry a failed portrait from the notice with the same scene builder. Co-authored-by: Cursor --- docs/phases/11-events/67-notice-image.md | 22 ++-- docs/protocol.md | 26 +++- src/HSchool.Client/src/i18n/strings.ts | 4 + src/HSchool.Client/src/main.ts | 1 + src/HSchool.Client/src/net/api.ts | 4 + src/HSchool.Client/src/net/protocol.test.ts | 8 +- src/HSchool.Client/src/net/protocol.ts | 5 + src/HSchool.Client/src/style.css | 26 ++++ src/HSchool.Client/src/ui/gameScreen.ts | 25 +++- .../src/ui/noticeModals.test.ts | 25 ++++ src/HSchool.Client/src/ui/noticeModals.ts | 46 ++++++- .../src/ui/noticeToasts.test.ts | 49 ++++--- src/HSchool.Client/src/ui/noticeToasts.ts | 68 +++++++--- src/HSchool.Protocol/Messages.cs | 10 +- src/HSchool.Protocol/ProtocolCodec.cs | 6 +- src/HSchool.Server/Api/DevEndpoints.cs | 5 +- src/HSchool.Server/Api/SchoolEndpoints.cs | 105 ++++++++++++--- src/HSchool.Server/Game/GameCommand.cs | 8 +- src/HSchool.Server/Game/GameLoopService.cs | 15 ++- .../Game/GenerationFailedPoster.cs | 32 +++++ src/HSchool.Server/Game/NoticeBoard.cs | 33 ++++- src/HSchool.Server/Game/PortraitService.cs | 3 + src/HSchool.Server/Game/SchoolStore.cs | 6 + .../Game/SchoolWorker.Mailbox.cs | 39 +++++- src/HSchool.Server/Game/SchoolWorker.Tick.cs | 2 +- src/HSchool.Server/Game/WorkerCommand.cs | 33 ++++- .../mods/core/defs/events/notices.jsonc | 2 +- .../NoticeImageApiTests.cs | 122 ++++++++++++++++++ tests/HSchool.Content.Tests/EventDefTests.cs | 2 +- .../ProtocolCodecTests.cs | 4 +- .../GenerationFailedPosterTests.cs | 43 ++++++ .../HSchool.Server.Tests/NoticeBoardTests.cs | 38 +++++- 32 files changed, 712 insertions(+), 105 deletions(-) create mode 100644 src/HSchool.Server/Game/GenerationFailedPoster.cs create mode 100644 tests/HSchool.AppHost.Tests/NoticeImageApiTests.cs create mode 100644 tests/HSchool.Server.Tests/GenerationFailedPosterTests.cs diff --git a/docs/phases/11-events/67-notice-image.md b/docs/phases/11-events/67-notice-image.md index 6dcdce0..e552796 100644 --- a/docs/phases/11-events/67-notice-image.md +++ b/docs/phases/11-events/67-notice-image.md @@ -13,26 +13,26 @@ ## Задачи -- [ ] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice` — +- [x] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice` — не рисовать, не 500 -- [ ] Ваниль `GenerationFailed`: error, pause, `generateImage`. Эмит из сервиса портретов при +- [x] Ваниль `GenerationFailed`: error, pause, `generateImage`. Эмит из сервиса портретов при неуспехе Swarm (тот же personId и kind). Утро и звонок остаются `none` -- [ ] UI: на модале/тосте с action — кнопка; вызов существующего портретного HTTP (или узкого +- [x] UI: на модале/тосте с action — кнопка; вызов существующего портретного HTTP (или узкого `POST .../notices/{id}/generate`, если так проще связать права). Kind по умолчанию `full`. Строки через `t(...)` -- [ ] Хозяин только. Гость кнопку не активирует. Успех не снимает паузу сам — нужен dismiss -- [ ] [`protocol.md`](../../protocol.md) — HTTP действия, тем же коммитом. Сокет не бампить +- [x] Хозяин только. Гость кнопку не активирует. Успех не снимает паузу сам — нужен dismiss +- [x] [`protocol.md`](../../protocol.md) — HTTP действия, тем же коммитом. Сокет не бампить повторно, если кадр 64 уже несёт action/personId; иначе добавить поля в том же смысле, что 64, без лишнего +1 если укладка уже есть ## Тесты, без которых фаза не закрыта -- [ ] Каталог: `GenerationFailed` с `generateImage`; `DayStarted` без кнопки -- [ ] Неуспех генерации ставит pausing notice с personId; успех — не ставит -- [ ] Хостовый: хозяин с кнопки получает PNG (или тот же outcome, что карточка); гость 403 -- [ ] Промпт этой генерации содержит сцену узла человека (как 66), не пустой коридор без людей -- [ ] Notice без personId и с `generateImage` не дергает Swarm -- [ ] Клиентский: кнопка есть только при action; info-тост дня кнопки не имеет +- [x] Каталог: `GenerationFailed` с `generateImage`; `DayStarted` без кнопки +- [x] Неуспех генерации ставит pausing notice с personId; успех — не ставит +- [x] Хостовый: хозяин с кнопки получает PNG (или тот же outcome, что карточка); гость 403 +- [x] Промпт этой генерации содержит сцену узла человека (как 66), не пустой коридор без людей +- [x] Notice без personId и с `generateImage` не дергает Swarm +- [x] Клиентский: кнопка есть только при action; info-тост дня кнопки не имеет ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 1c45235..0713aab 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 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 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` 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` -`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 @@ -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. Info is not written to the save and is not resent on OpenSchool. Warning/error with `pause=1` stop the clock, stay in the save (at most eight), and are sent again on Open. The ninth sticky -notice is not emitted. `personId` is `0` when nobody is in frame (the generate-image button is a -later phase). +notice is not emitted. `personId` is `0` when nobody is in frame. `action` is the catalog value +(`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 | | --- | --- | --- | @@ -1112,6 +1129,7 @@ later phase). | … | `u8` | `1` pause (clocks stop until dismiss + Play), `0` clocks keep running | | … | `u32` | `ttlMs`; `0` stays until dismiss | | … | `u32` | `personId`; `0` = none | +| … | string | `action`; `none` or `generateImage` | ## Guarantees and limits diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 3e42fb3..39a6ae0 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -387,6 +387,8 @@ const ru = { LessonStarted: 'Начало урока', GenerationFailed: 'Не удалось нарисовать портрет', noticeDismiss: 'Закрыть', + noticeGenerateImage: 'Создать картинку', + noticeGenerateImageBusy: 'Рисуем…', noticePauseLocked: 'Закройте предупреждение, чтобы продолжить', } as const; @@ -779,6 +781,8 @@ const en: Messages = { LessonStarted: 'A lesson has started', GenerationFailed: 'Portrait generation failed', noticeDismiss: 'Close', + noticeGenerateImage: 'Create image', + noticeGenerateImageBusy: 'Drawing…', noticePauseLocked: 'Close the warning to resume', }; diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index 9fcc5f4..38ccb8c 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -44,6 +44,7 @@ async function bootstrap(): Promise { onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex), onSkip: () => connection.skipEmpty(), onDismissNotice: (id) => connection.dismissNotice(id), + onGenerateNotice: (id) => void game.generateFromNotice(id), }); const connection = new GameConnection(gameSocketUrl(), { diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index f041e0b..166ccb6 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -661,6 +661,10 @@ export async function generatePortrait( ); } +export async function generateNoticePortrait(schoolId: number, noticeId: number): Promise { + return request(`/api/schools/${schoolId}/notices/${noticeId}/generate`, { method: 'POST' }); +} + export interface DirectoryPerson { readonly id: string; readonly fullName: string; diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 7807b7e..bb848f6 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -446,8 +446,10 @@ describe('decodeServerMessage', () => { it('reads a notice frame by the documented offsets', () => { const defName = 'DayStarted'; + const action = 'none'; 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); view.setUint8(0, MessageType.ServerNotice); view.setUint32(1, 0x0a0b0c0d, true); @@ -458,6 +460,9 @@ describe('decodeServerMessage', () => { view.setUint8(afterName + 1, 0); view.setUint32(afterName + 2, 8000, 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({ type: 'notice', @@ -467,6 +472,7 @@ describe('decodeServerMessage', () => { pause: false, ttlMs: 8000, personId: 0, + action: 'none', }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 6d574ad..6cd85bb 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -150,6 +150,8 @@ export interface NoticeMessage { readonly ttlMs: number; /** 0 when nobody is in frame. */ readonly personId: number; + /** Catalog action: `none` or `generateImage`. */ + readonly action: string; } export type ServerMessage = @@ -443,6 +445,8 @@ function decodeNotice(view: DataView): NoticeMessage { const ttlMs = view.getUint32(offset, true); offset += 4; const personId = view.getUint32(offset, true); + offset += 4; + const action = readString(view, offset); return { type: 'notice', @@ -452,6 +456,7 @@ function decodeNotice(view: DataView): NoticeMessage { pause: pause !== 0, ttlMs, personId, + action: action.text, }; } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 1ce2280..84b0f3d 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -1634,6 +1634,25 @@ body { 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 { position: absolute; inset: 0; @@ -1672,3 +1691,10 @@ body { font-size: 15px; } +.notice-modal__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 7bb607a..2fcacf3 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -12,7 +12,7 @@ import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeek import { formatWeather } from '../format/weather.ts'; import { getLocale } from '../i18n/locale.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 { ManagementPanel } from './managementPanel.ts'; import { NoticeToasts } from './noticeToasts.ts'; @@ -37,6 +37,7 @@ interface GameScreenOptions { readonly onSetSpeed: (speedIndex: number) => void; readonly onSkip: () => void; readonly onDismissNotice?: (id: number) => void; + readonly onGenerateNotice?: (id: number) => void; } const SPEED_LABELS = ['×½', '×1', '×2', '×5', '×10']; @@ -118,11 +119,17 @@ export class GameScreen { private leftTab: LeftTab = 'map'; 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( (id) => options.onDismissNotice?.(id), () => this.canManage, () => this.paintPlay(), + (id) => options.onGenerateNotice?.(id), + () => this.canManage, ); this.speedButtons = CLOCK_SPEEDS.map((_, index) => el('button', { @@ -334,6 +341,20 @@ export class GameScreen { this.notices.show(message); } + async generateFromNotice(id: number): Promise { + 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. */ applyDirectory(people: readonly { id: string; fullName: string }[]): void { this.directory = new Map(people.map((person) => [person.id, person.fullName])); diff --git a/src/HSchool.Client/src/ui/noticeModals.test.ts b/src/HSchool.Client/src/ui/noticeModals.test.ts index 0df93e7..3d4ce71 100644 --- a/src/HSchool.Client/src/ui/noticeModals.test.ts +++ b/src/HSchool.Client/src/ui/noticeModals.test.ts @@ -21,6 +21,7 @@ function pausing(id: number, defName = 'GenerationFailed'): Parameters { expect(modals.element.querySelector('button')).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(); + }); }); diff --git a/src/HSchool.Client/src/ui/noticeModals.ts b/src/HSchool.Client/src/ui/noticeModals.ts index 26d47d7..d590c07 100644 --- a/src/HSchool.Client/src/ui/noticeModals.ts +++ b/src/HSchool.Client/src/ui/noticeModals.ts @@ -1,20 +1,24 @@ 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'; +import { noticeHasGenerate, 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. + * 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 { readonly element = el('div', { class: 'notice-modals', hidden: true }); private readonly queue: NoticeMessage[] = []; + private generatingId: number | null = null; constructor( private readonly onDismiss: (id: number) => void, private readonly canDismiss: () => boolean, private readonly onChange: () => void, + private readonly onGenerate?: (id: number) => void, + private readonly canGenerate: () => boolean = () => false, ) {} get open(): boolean { @@ -37,6 +41,7 @@ export class NoticeModals { clear(): void { this.queue.length = 0; + this.generatingId = null; this.paint(); this.onChange(); } @@ -45,12 +50,21 @@ export class NoticeModals { this.paint(); } + setGenerating(id: number | null): void { + this.generatingId = id; + this.paint(); + } + private dismissCurrent(): void { const current = this.queue.shift(); if (current === undefined) { return; } + if (this.generatingId === current.id) { + this.generatingId = null; + } + this.onDismiss(current.id); this.paint(); this.onChange(); @@ -65,14 +79,32 @@ export class NoticeModals { } this.element.hidden = false; - const close = this.canDismiss() - ? el('button', { + const actions = el('div', { class: 'notice-modal__actions' }); + 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', type: 'button', text: t('noticeDismiss' satisfies MessageKey), onClick: () => this.dismissCurrent(), - }) - : null; + }), + ); + } + const card = el( 'div', { @@ -80,7 +112,7 @@ export class NoticeModals { dataset: { noticeId: String(current.id), noticeDef: current.defName }, }, el('p', { class: 'notice-modal__text', text: noticeLabel(current.defName) }), - close, + actions.childElementCount > 0 ? actions : null, ); this.element.replaceChildren(card); } diff --git a/src/HSchool.Client/src/ui/noticeToasts.test.ts b/src/HSchool.Client/src/ui/noticeToasts.test.ts index ef340cd..5089fc4 100644 --- a/src/HSchool.Client/src/ui/noticeToasts.test.ts +++ b/src/HSchool.Client/src/ui/noticeToasts.test.ts @@ -12,6 +12,19 @@ afterEach(() => { setLocale('ru'); }); +function info(id: number, defName: string, action = 'none', personId = 0): Parameters[0] { + return { + type: 'notice', + id, + defName, + severity: 0, + pause: false, + ttlMs: 8000, + personId, + action, + }; +} + describe('NoticeToasts', () => { it('shows the localized defName and click sends dismiss', () => { setLocale('ru'); @@ -19,18 +32,11 @@ describe('NoticeToasts', () => { const toasts = new NoticeToasts(onDismiss); document.body.append(toasts.element); - toasts.show({ - type: 'notice', - id: 7, - defName: 'DayStarted', - severity: 0, - pause: false, - ttlMs: 8000, - personId: 0, - }); + toasts.show(info(7, 'DayStarted')); const toast = toasts.element.querySelector('.notice-toast'); expect(toast?.textContent).toBe(t('DayStarted')); + expect(toast?.querySelector('[data-notice-action="generate"]')).toBeNull(); expect(document.querySelector('.events-column')).toBeNull(); expect(document.querySelector('[data-events-column]')).toBeNull(); @@ -45,18 +51,25 @@ describe('NoticeToasts', () => { const toasts = new NoticeToasts(onDismiss); document.body.append(toasts.element); - toasts.show({ - type: 'notice', - id: 3, - defName: 'LessonStarted', - severity: 0, - pause: false, - ttlMs: 8000, - personId: 0, - }); + toasts.show(info(3, 'LessonStarted')); vi.advanceTimersByTime(8000); expect(onDismiss).not.toHaveBeenCalled(); 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); + }); }); diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts index 85499e4..38c7065 100644 --- a/src/HSchool.Client/src/ui/noticeToasts.ts +++ b/src/HSchool.Client/src/ui/noticeToasts.ts @@ -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 - * 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 { readonly element = el('div', { class: 'notice-toasts' }); private readonly timers = new Map>(); - 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 { this.remove(notice.id); @@ -37,17 +46,7 @@ export class NoticeToasts { this.remove(Number.isFinite(oldestId) ? oldestId : 0, false); } - const toast = el( - '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); + this.element.append(this.paintToast(notice)); if (notice.ttlMs > 0) { this.timers.set( @@ -66,14 +65,53 @@ export class NoticeToasts { } localize(): void { - for (const node of this.element.querySelectorAll('[data-notice-def]')) { - const defName = node.dataset.noticeDef; + for (const node of this.element.querySelectorAll('[data-notice-label]')) { + const defName = node.dataset.noticeLabel; if (defName !== undefined) { 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 { const timer = this.timers.get(id); if (timer !== undefined) { diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index 03cff5a..bbbd987 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -95,6 +95,13 @@ public static class NoticeSeverity public const byte Error = 2; } +/// Wire values for . +public static class NoticeAction +{ + public const string None = "none"; + public const string GenerateImage = "generateImage"; +} + /// /// One school event for open clients. is 0 when nobody is in frame. /// 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, bool Pause, uint TtlMs, - uint PersonId = 0); + uint PersonId = 0, + string Action = NoticeAction.None); /// Where people are: 1 in a node, 2 walking through it. Off campus is omitted. public static class PresenceState diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index 7dcaaa0..c174e14 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -280,7 +280,7 @@ public static class ProtocolCodec } 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 destination, in ServerNoticeMessage message) { @@ -292,6 +292,7 @@ public static class ProtocolCodec writer.WriteByte(message.Pause ? (byte)1 : (byte)0); writer.WriteUInt32(message.TtlMs); writer.WriteUInt32(message.PersonId); + writer.WriteString(message.Action); return writer.Position; } @@ -482,7 +483,8 @@ public static class ProtocolCodec var pause = reader.ReadByte() != 0; var ttlMs = 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) => diff --git a/src/HSchool.Server/Api/DevEndpoints.cs b/src/HSchool.Server/Api/DevEndpoints.cs index c36c0b2..eb03b40 100644 --- a/src/HSchool.Server/Api/DevEndpoints.cs +++ b/src/HSchool.Server/Api/DevEndpoints.cs @@ -60,7 +60,8 @@ internal static class DevEndpoints var command = new GameCommand.PostSchoolNotice( id, defName, - request?.PersonId ?? 0, + request?.Person?.Trim() ?? "", + request?.Kind?.Trim() ?? "", NewCompletion()); commands.Enqueue(command); var posted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); @@ -153,4 +154,4 @@ internal sealed record SchoolDumpLessonResponse( int Day, 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); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 533f474..3be6ead 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -396,26 +396,7 @@ internal static class SchoolEndpoints } var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken); - return result.Outcome switch - { - 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."), - }; + return PortraitGenerationHttp(id, personId, result); }) .WithName("GenerateSchoolPersonPortrait"); @@ -735,6 +716,58 @@ internal static class SchoolEndpoints return Results.NoContent(); }) .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()); + 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"); } /// The supervisor must never be blocked by a continuation of a waiting request. @@ -999,6 +1032,38 @@ internal static class SchoolEndpoints return true; } + private static async Task 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( int statusCode, string code, diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 00d5738..bb7a39b 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -49,9 +49,15 @@ internal abstract record GameCommand internal sealed record PostSchoolNotice( int SchoolId, string DefName, - uint PersonId, + string PersonKey, + string Kind, TaskCompletionSource Result) : GameCommand; + internal sealed record GetNoticePortraitTarget( + int SchoolId, + uint NoticeId, + TaskCompletionSource Result) : GameCommand; + /// Stops every worker, re-reads the save directory, starts workers from those files. internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 79f34a1..0e142fe 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -237,6 +237,10 @@ internal sealed class GameLoopService( HandlePostNotice(postNotice); break; + case GameCommand.GetNoticePortraitTarget getTarget: + HandleGetNoticePortraitTarget(getTarget); + break; + case GameCommand.ReloadSaves reload: await HandleReloadAsync(reload).ConfigureAwait(false); break; @@ -690,12 +694,21 @@ internal sealed class GameLoopService( 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))) + || !worker.Post(new WorkerCommand.PostNotice(command.DefName, command.PersonKey, command.Kind, command.Result))) { 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() { var saves = store.LoadAll(); diff --git a/src/HSchool.Server/Game/GenerationFailedPoster.cs b/src/HSchool.Server/Game/GenerationFailedPoster.cs new file mode 100644 index 0000000..ee8e4f3 --- /dev/null +++ b/src/HSchool.Server/Game/GenerationFailedPoster.cs @@ -0,0 +1,32 @@ +namespace HSchool.Server.Game; + +/// +/// Swarm failures raise GenerationFailed on the school's board. Success does not, and a +/// missing person id never posts. +/// +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(TaskCreationOptions.RunContinuationsAsynchronously))); + } +} diff --git a/src/HSchool.Server/Game/NoticeBoard.cs b/src/HSchool.Server/Game/NoticeBoard.cs index 3bfc55a..6a8a1c2 100644 --- a/src/HSchool.Server/Game/NoticeBoard.cs +++ b/src/HSchool.Server/Game/NoticeBoard.cs @@ -18,7 +18,14 @@ internal sealed class StickyNotice 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() { @@ -28,6 +35,9 @@ internal sealed class StickyNotice Pause = Pause, TtlMs = TtlMs, PersonId = PersonId, + PersonKey = PersonKey, + Kind = Kind, + Action = Action, }; public static byte WireSeverity(string severity) => severity switch @@ -36,6 +46,9 @@ internal sealed class StickyNotice EventSeverities.Error => NoticeSeverity.Error, _ => NoticeSeverity.Info, }; + + public static uint WirePersonId(string personKey) => + string.IsNullOrWhiteSpace(personKey) ? 0u : 1u; } /// @@ -74,7 +87,10 @@ internal sealed class NoticeBoard Severity = row.Severity, Pause = true, 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) { @@ -91,11 +107,13 @@ internal sealed class NoticeBoard public IReadOnlyList ToSave() => [.. _sticky.Select(notice => notice.ToSave())]; + public StickyNotice? Find(uint id) => _sticky.Find(notice => notice.Id == id); + /// /// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast /// only. Returns when the sticky ceiling would be exceeded. /// - public bool TryPost(EventDef def, uint personId, out ServerNoticeMessage message) + public bool TryPost(EventDef def, string personKey, string kind, out ServerNoticeMessage message) { message = default; if (def.Pause && _sticky.Count >= MaxSticky) @@ -104,13 +122,15 @@ internal sealed class NoticeBoard } var id = ++_nextId; + var wirePersonId = StickyNotice.WirePersonId(personKey); message = new ServerNoticeMessage( id, def.DefName, StickyNotice.WireSeverity(def.Severity), def.Pause, (uint)Math.Max(0, def.TtlMs), - personId); + wirePersonId, + def.Action); if (def.Pause) { _sticky.Add(new StickyNotice @@ -120,7 +140,10 @@ internal sealed class NoticeBoard Severity = message.Severity, Pause = true, TtlMs = message.TtlMs, - PersonId = personId, + PersonId = wirePersonId, + PersonKey = personKey ?? "", + Kind = kind ?? "", + Action = def.Action, }); } diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index bc1015b..835aa48 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -156,16 +156,19 @@ internal sealed class PortraitService( catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId); + GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind); return PortraitGenerationResult.TimedOut; } catch (HttpRequestException ex) { logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId); + GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind); return PortraitGenerationResult.Unavailable; } catch (Exception ex) { logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId); + GenerationFailedPoster.TryEnqueue(commands, schoolId, personId, kind); return PortraitGenerationResult.Unavailable; } } diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 74dafd3..3c70795 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -65,6 +65,12 @@ internal sealed class StickyNoticeSave public uint TtlMs { get; init; } public uint PersonId { get; init; } + + public string PersonKey { get; init; } = ""; + + public string Kind { get; init; } = ""; + + public string Action { get; init; } = ""; } /// Allocates school ids that survive a process restart. diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs index a0daad0..cf6cfc3 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -91,7 +91,7 @@ internal sealed partial class SchoolWorker break; } - post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonId)); + post.Result.TrySetResult(TryEmitNotice(school, def, post.PersonKey, post.Kind)); break; } @@ -101,6 +101,10 @@ internal sealed partial class SchoolWorker dismissHttp.Result.TrySetResult(); break; + case WorkerCommand.GetNoticePortraitTarget getTarget: + getTarget.Result.TrySetResult(ReadNoticePortraitTarget(getTarget.NoticeId)); + break; + case WorkerCommand.Dump dump: dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); break; @@ -227,6 +231,9 @@ internal sealed partial class SchoolWorker case WorkerCommand.PostNotice post: post.Result.TrySetResult(null); break; + case WorkerCommand.GetNoticePortraitTarget getTarget: + getTarget.Result.TrySetResult(NoticePortraitTarget.UnknownSchool); + break; case WorkerCommand.DismissNoticeHttp dismissHttp: dismissHttp.Result.TrySetResult(); break; @@ -280,6 +287,9 @@ internal sealed partial class SchoolWorker case WorkerCommand.PostNotice post: post.Result.TrySetException(exception); break; + case WorkerCommand.GetNoticePortraitTarget getTarget: + getTarget.Result.TrySetException(exception); + break; case WorkerCommand.DismissNoticeHttp dismissHttp: dismissHttp.Result.TrySetException(exception); break; @@ -454,9 +464,32 @@ internal sealed partial class SchoolWorker 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; } diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs index f8a553d..7f817e2 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs @@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker continue; } - TryEmitNotice(school, def, personId: 0); + TryEmitNotice(school, def); } } } diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 25cf8a0..829ad75 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -29,9 +29,14 @@ internal abstract record WorkerCommand /// Test/dev post onto the school board. Looks up an EventDef by name. internal sealed record PostNotice( string DefName, - uint PersonId, + string PersonKey, + string Kind, TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetNoticePortraitTarget( + uint NoticeId, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record Dump(TaskCompletionSource Result) : WorkerCommand; internal sealed record GetPerson( @@ -96,3 +101,29 @@ internal abstract record WorkerCommand } 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); +} diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc index 477e7a9..e46876e 100644 --- a/src/HSchool.Server/mods/core/defs/events/notices.jsonc +++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc @@ -21,6 +21,6 @@ "pause": true, "ttlMs": 0, "trigger": "generationFailed", - "action": "none", + "action": "generateImage", }, ] diff --git a/tests/HSchool.AppHost.Tests/NoticeImageApiTests.cs b/tests/HSchool.AppHost.Tests/NoticeImageApiTests.cs new file mode 100644 index 0000000..c053a29 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/NoticeImageApiTests.cs @@ -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(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 FirstPersonIdAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/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 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(TestContext.Current.CancellationToken); + Assert.NotNull(body); + return body; + } + + private sealed record PeopleListResponse(int Total, IReadOnlyList 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); +} diff --git a/tests/HSchool.Content.Tests/EventDefTests.cs b/tests/HSchool.Content.Tests/EventDefTests.cs index 46b7f87..e6cd997 100644 --- a/tests/HSchool.Content.Tests/EventDefTests.cs +++ b/tests/HSchool.Content.Tests/EventDefTests.cs @@ -30,7 +30,7 @@ public class EventDefTests Assert.True(catalog.Events["GenerationFailed"].Pause); Assert.Equal(0, catalog.Events["GenerationFailed"].TtlMs); Assert.Equal(EventTriggers.GenerationFailed, catalog.Events["GenerationFailed"].Trigger); - Assert.Equal(EventActions.None, catalog.Events["GenerationFailed"].Action); + Assert.Equal(EventActions.GenerateImage, catalog.Events["GenerationFailed"].Action); Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"])); Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"])); } diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index bf47e91..3778cc9 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -113,7 +113,7 @@ public class ProtocolCodecTests [Fact] 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); Span buffer = stackalloc byte[size]; @@ -128,6 +128,8 @@ public class ProtocolCodecTests Assert.Equal(0, buffer[18]); Assert.Equal(8000u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer[19..23])); 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])); } diff --git a/tests/HSchool.Server.Tests/GenerationFailedPosterTests.cs b/tests/HSchool.Server.Tests/GenerationFailedPosterTests.cs new file mode 100644 index 0000000..9307bee --- /dev/null +++ b/tests/HSchool.Server.Tests/GenerationFailedPosterTests.cs @@ -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(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 _)); + } +} diff --git a/tests/HSchool.Server.Tests/NoticeBoardTests.cs b/tests/HSchool.Server.Tests/NoticeBoardTests.cs index e2f711b..18488a1 100644 --- a/tests/HSchool.Server.Tests/NoticeBoardTests.cs +++ b/tests/HSchool.Server.Tests/NoticeBoardTests.cs @@ -11,12 +11,12 @@ public class NoticeBoardTests { 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(board.HasPausing); 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(board.HasPausing); Assert.Equal("GenerationFailed", Assert.Single(board.Sticky).DefName); @@ -29,10 +29,10 @@ public class NoticeBoardTests var board = new NoticeBoard(maxSticky: 8); 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(8, board.Sticky.Count); } @@ -41,8 +41,8 @@ public class NoticeBoardTests 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.TryPost(Pausing(), personKey: "", kind: "", out var first)); + Assert.True(board.TryPost(Pausing(), personKey: "", kind: "", out var second)); Assert.True(board.TryDismiss(first.Id)); Assert.True(board.HasPausing); @@ -71,6 +71,30 @@ public class NoticeBoardTests 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() { DefName = "DayStarted", @@ -88,6 +112,6 @@ public class NoticeBoardTests Pause = true, TtlMs = 0, Trigger = EventTriggers.GenerationFailed, - Action = EventActions.None, + Action = EventActions.GenerateImage, }; }