Merge branch 'phase/67-notice-image'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 02:31:26 +03:00
co-authored by Cursor
33 changed files with 713 additions and 106 deletions
+11 -11
View File
@@ -13,26 +13,26 @@
## Задачи
- [ ] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice`
- [x] `EventDef.action`: `none` | `generateImage`. `generateImage` без `personId` на `Notice`
не рисовать, не 500
- [ ] Ваниль `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-тост дня кнопки не имеет
## Критерий готовности
+1 -1
View File
@@ -35,6 +35,6 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [67. Картинка с уведомления](67-notice-image.md) | 🔄 | `generateImage`, кнопка, тот же сборщик |
| [67. Картинка с уведомления](67-notice-image.md) | | `generateImage`, кнопка, тот же сборщик |
67 стоит на 65 и 66.
+22 -4
View File
@@ -814,6 +814,20 @@ Owner-only close of a pausing notice. `204` even when that id is already gone. G
sticky warning/error. Socket dismiss (`0x08`) is the live intent; this HTTP route is the
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
+4
View File
@@ -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',
};
+1
View File
@@ -44,6 +44,7 @@ async function bootstrap(): Promise<void> {
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
onSkip: () => connection.skipEmpty(),
onDismissNotice: (id) => connection.dismissNotice(id),
onGenerateNotice: (id) => void game.generateFromNotice(id),
});
const connection = new GameConnection(gameSocketUrl(), {
+4
View File
@@ -661,6 +661,10 @@ export async function generatePortrait(
);
}
export async function generateNoticePortrait(schoolId: number, noticeId: number): Promise<PortraitResult> {
return request<PortraitResult>(`/api/schools/${schoolId}/notices/${noticeId}/generate`, { method: 'POST' });
}
export interface DirectoryPerson {
readonly id: string;
readonly fullName: string;
+7 -1
View File
@@ -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',
});
});
+5
View File
@@ -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,
};
}
+26
View File
@@ -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;
}
+23 -2
View File
@@ -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<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. */
applyDirectory(people: readonly { id: string; fullName: string }[]): void {
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,
ttlMs: 8000,
personId: 0,
action: 'none',
};
}
@@ -66,4 +67,28 @@ describe('NoticeModals', () => {
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();
});
});
+39 -7
View File
@@ -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);
}
+31 -18
View File
@@ -12,6 +12,19 @@ afterEach(() => {
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', () => {
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);
});
});
+53 -15
View File
@@ -15,15 +15,24 @@ export function noticeLabel(defName: string): string {
}
}
export function noticeHasGenerate(notice: NoticeMessage): boolean {
return notice.action === 'generateImage' && notice.personId !== 0;
}
/**
* Info toasts over the school screen. Not a column: a stack in the corner that TTL and click
* 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<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 {
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<HTMLElement>('[data-notice-def]')) {
const defName = node.dataset.noticeDef;
for (const node of this.element.querySelectorAll<HTMLElement>('[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) {
+9 -1
View File
@@ -95,6 +95,13 @@ public static class NoticeSeverity
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>
/// 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.
@@ -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);
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
public static class PresenceState
+4 -2
View File
@@ -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<byte> 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) =>
+3 -2
View File
@@ -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<PostedNotice?>());
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);
+85 -20
View File
@@ -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<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>
@@ -999,6 +1032,38 @@ internal static class SchoolEndpoints
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(
int statusCode,
string code,
+7 -1
View File
@@ -49,9 +49,15 @@ internal abstract record GameCommand
internal sealed record PostSchoolNotice(
int SchoolId,
string DefName,
uint PersonId,
string PersonKey,
string Kind,
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>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
+14 -1
View File
@@ -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();
@@ -0,0 +1,32 @@
namespace HSchool.Server.Game;
/// <summary>
/// Swarm failures raise <c>GenerationFailed</c> on the school's board. Success does not, and a
/// missing person id never posts.
/// </summary>
internal static class GenerationFailedPoster
{
public const string DefName = "GenerationFailed";
public static bool ShouldPost(PortraitGenerationOutcome outcome) =>
outcome is PortraitGenerationOutcome.Unavailable or PortraitGenerationOutcome.TimedOut;
public static void TryEnqueue(
GameCommandQueue commands,
int schoolId,
string personId,
PortraitKind kind)
{
if (string.IsNullOrWhiteSpace(personId))
{
return;
}
commands.Enqueue(new GameCommand.PostSchoolNotice(
schoolId,
DefName,
personId,
PortraitKindParser.ToApiValue(kind),
new TaskCompletionSource<PostedNotice?>(TaskCreationOptions.RunContinuationsAsynchronously)));
}
}
+28 -5
View File
@@ -18,7 +18,14 @@ internal sealed class StickyNotice
public required uint PersonId { get; init; }
public 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;
}
/// <summary>
@@ -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<StickyNoticeSave> ToSave() => [.. _sticky.Select(notice => notice.ToSave())];
public StickyNotice? Find(uint id) => _sticky.Find(notice => notice.Id == id);
/// <summary>
/// Allocates a notice. Pausing ones are stored until dismiss; info is returned for broadcast
/// only. Returns <see langword="false"/> when the sticky ceiling would be exceeded.
/// </summary>
public bool TryPost(EventDef def, uint personId, out ServerNoticeMessage message)
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,
});
}
@@ -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;
}
}
+6
View File
@@ -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; } = "";
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -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;
}
+1 -1
View File
@@ -515,7 +515,7 @@ internal sealed partial class SchoolWorker
continue;
}
TryEmitNotice(school, def, personId: 0);
TryEmitNotice(school, def);
}
}
}
+32 -1
View File
@@ -29,9 +29,14 @@ internal abstract record WorkerCommand
/// <summary>Test/dev post onto the school board. Looks up an EventDef by name.</summary>
internal sealed record PostNotice(
string DefName,
uint PersonId,
string PersonKey,
string Kind,
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 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);
}
@@ -21,6 +21,6 @@
"pause": true,
"ttlMs": 0,
"trigger": "generationFailed",
"action": "none",
"action": "generateImage",
},
]
@@ -0,0 +1,122 @@
using System.Net;
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class NoticeImageApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task OwnerGenerate_MatchesCardOutcome_GuestIs403()
{
using var owner = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticeImageOwner");
await SchoolApiTests.WipeAllSavesAsync(owner);
var school = await SchoolApiTests.CreateAsync(owner, "Кнопка хозяина", Start);
var personId = await FirstPersonIdAsync(owner, school.Id);
var posted = await PostNoticeAsync(owner, school.Id, "GenerationFailed", personId, "full");
using var card = await owner.PostAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=full",
content: null,
TestContext.Current.CancellationToken);
using var fromNotice = await owner.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(card.StatusCode, fromNotice.StatusCode);
Assert.Equal(await SchoolApiTests.ProblemCodeAsync(card), await SchoolApiTests.ProblemCodeAsync(fromNotice));
var paused = (await SchoolApiTests.GetSchoolsAsync(owner)).Schools.Single(item => item.Id == school.Id);
Assert.False(paused.Running);
using var guest = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticeImageGuest");
using var forbidden = await guest.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(forbidden));
var stillPaused = (await SchoolApiTests.GetSchoolsAsync(owner)).Schools.Single(item => item.Id == school.Id);
Assert.False(stillPaused.Running);
}
[Fact]
public async Task GenerateWithoutPerson_DoesNotCallSwarm()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Без человека", Start);
var posted = await PostNoticeAsync(client, school.Id, "GenerationFailed");
using var response = await client.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/generate",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var code = await SchoolApiTests.ProblemCodeAsync(response);
Assert.Equal("notice-cannot-generate", code);
Assert.NotEqual("swarmui-not-configured", code);
}
[Fact]
public async Task NoticePersonPrompt_ContainsThePersonNotAnEmptyCorridor()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Сцена с человеком", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
await PostNoticeAsync(client, school.Id, "GenerationFailed", personId, "full");
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait/prompt?kind=full",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<PortraitPromptPayload>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains("years old", payload!.Positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("wearing", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotMatch(@"\bage \d+", payload.Positive);
Assert.DoesNotContain("empty corridor", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
}
private static async Task<string> FirstPersonIdAsync(HttpClient client, int schoolId)
{
var page = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?pageSize=1",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
Assert.NotEmpty(page!.People);
return page.People[0].Id;
}
private static async Task<DevNoticeResponse> PostNoticeAsync(
HttpClient client,
int schoolId,
string defName,
string? person = null,
string? kind = null)
{
using var response = await client.PostAsJsonAsync(
$"/api/dev/schools/{schoolId}/notices",
new { defName, person, kind },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<DevNoticeResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
return body;
}
private sealed record PeopleListResponse(int Total, IReadOnlyList<PersonListItem> People);
private sealed record PersonListItem(string Id);
private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause);
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative);
}
+1 -1
View File
@@ -30,7 +30,7 @@ public class EventDefTests
Assert.True(catalog.Events["GenerationFailed"].Pause);
Assert.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"]));
}
@@ -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<byte> 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]));
}
@@ -0,0 +1,43 @@
using HSchool.Server.Game;
namespace HSchool.Server.Tests;
public class GenerationFailedPosterTests
{
[Fact]
public void SwarmFailure_ShouldPost_SuccessAndNotConfiguredShouldNot()
{
Assert.True(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.Unavailable));
Assert.True(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.TimedOut));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.Succeeded));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.NotConfigured));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.InvalidPrompt));
Assert.False(GenerationFailedPoster.ShouldPost(PortraitGenerationOutcome.UnknownPerson));
}
[Fact]
public async Task TryEnqueue_WithPerson_PostsGenerationFailed()
{
var queue = new GameCommandQueue();
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: "a0.p0", PortraitKind.Avatar);
var command = await queue.Reader.ReadAsync(TestContext.Current.CancellationToken);
var post = Assert.IsType<GameCommand.PostSchoolNotice>(command);
Assert.Equal(4, post.SchoolId);
Assert.Equal(GenerationFailedPoster.DefName, post.DefName);
Assert.Equal("a0.p0", post.PersonKey);
Assert.Equal("avatar", post.Kind);
}
[Fact]
public void TryEnqueue_WithoutPerson_DoesNotPost()
{
var queue = new GameCommandQueue();
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: "", PortraitKind.Full);
GenerationFailedPoster.TryEnqueue(queue, schoolId: 4, personId: " ", PortraitKind.Full);
Assert.False(queue.Reader.TryRead(out _));
}
}
+31 -7
View File
@@ -11,12 +11,12 @@ public class NoticeBoardTests
{
var board = new NoticeBoard(maxSticky: 8);
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,
};
}