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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 01:48:30 +03:00
co-authored by Cursor
parent 3c70227229
commit 3061e09d3e
32 changed files with 712 additions and 105 deletions
+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",
},
]