Merge branch 'phase/67-notice-image'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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-тост дня кнопки не имеет
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,6 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [67. Картинка с уведомления](67-notice-image.md) | 🔄 | `generateImage`, кнопка, тот же сборщик |
|
| [67. Картинка с уведомления](67-notice-image.md) | ✅ | `generateImage`, кнопка, тот же сборщик |
|
||||||
|
|
||||||
67 стоит на 65 и 66.
|
67 стоит на 65 и 66.
|
||||||
|
|||||||
+22
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(), {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
TryEmitNotice(school, def, personId: 0);
|
TryEmitNotice(school, def);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user