From dfa9a25dca80cd79702a5a571e4156a4e264da0b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 04:14:41 +0300 Subject: [PATCH] Implement portrait generation and retrieval for people in the school system. Added new API endpoints for generating and fetching portraits, including support for avatar and full-body images. Updated the client-side to handle portrait states and display options. Enhanced the person card UI to include tabs for overview and portrait, with appropriate localization strings. Updated solution files to include new test projects. Adjusted server configuration for SwarmUI integration. --- docs/protocol.md | 26 ++- h-school.sln | 15 ++ src/HSchool.AppHost/AppHost.cs | 3 +- src/HSchool.Client/src/i18n/strings.ts | 22 +++ src/HSchool.Client/src/net/api.ts | 38 ++++ src/HSchool.Client/src/style.css | 56 ++++++ src/HSchool.Client/src/ui/applicantsDialog.ts | 26 ++- src/HSchool.Client/src/ui/dom.ts | 12 ++ .../src/ui/managementPanel.test.ts | 43 ++++- src/HSchool.Client/src/ui/managementPanel.ts | 51 ++--- src/HSchool.Client/src/ui/peoplePanel.ts | 39 ++-- src/HSchool.Client/src/ui/personCard.test.ts | 86 ++++++--- src/HSchool.Client/src/ui/personCard.ts | 160 +++++++++++++--- src/HSchool.Client/src/ui/personCardHost.ts | 138 ++++++++++++++ src/HSchool.Server/Api/PeopleModels.cs | 4 +- src/HSchool.Server/Api/SchoolEndpoints.cs | 85 ++++++++- src/HSchool.Server/Game/PortraitKind.cs | 28 +++ .../Game/PortraitPromptBuilder.cs | 48 +++++ src/HSchool.Server/Game/PortraitService.cs | 138 ++++++++++++++ src/HSchool.Server/Game/SchoolStore.cs | 51 +++++ src/HSchool.Server/Game/SwarmUiClient.cs | 180 ++++++++++++++++++ src/HSchool.Server/Game/SwarmUiSettings.cs | 63 ++++++ src/HSchool.Server/HSchool.Server.csproj | 8 + src/HSchool.Server/Program.cs | 23 ++- src/HSchool.Server/SwarmUiOptions.cs | 14 ++ src/HSchool.Server/appsettings.json | 5 + src/HSchool.Server/swarmui.json | 20 ++ .../HSchool.AppHost.Tests/PortraitApiTests.cs | 113 +++++++++++ tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 2 +- .../HSchool.Server.Tests.csproj | 24 +++ .../PortraitPromptBuilderTests.cs | 72 +++++++ .../SwarmUiClientTests.cs | 66 +++++++ 32 files changed, 1538 insertions(+), 121 deletions(-) create mode 100644 src/HSchool.Client/src/ui/personCardHost.ts create mode 100644 src/HSchool.Server/Game/PortraitKind.cs create mode 100644 src/HSchool.Server/Game/PortraitPromptBuilder.cs create mode 100644 src/HSchool.Server/Game/PortraitService.cs create mode 100644 src/HSchool.Server/Game/SwarmUiClient.cs create mode 100644 src/HSchool.Server/Game/SwarmUiSettings.cs create mode 100644 src/HSchool.Server/SwarmUiOptions.cs create mode 100644 src/HSchool.Server/swarmui.json create mode 100644 tests/HSchool.AppHost.Tests/PortraitApiTests.cs create mode 100644 tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj create mode 100644 tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs create mode 100644 tests/HSchool.Server.Tests/SwarmUiClientTests.cs diff --git a/docs/protocol.md b/docs/protocol.md index a2d772e..2dc196c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -262,6 +262,10 @@ no Chemistry; a related tongue from the name set may sit beside the native at a overload does not slow walking. Home and locker stay in `people.json` and are not on this card. The people list does not include any of these fields. +`hasAvatar` and `hasFullBody` tell the client whether PNG files already exist on disk for this +person. They are filled on the HTTP thread after the worker returns the card; generation does +not happen on this request. + ```json { "id": "f0.c0", @@ -308,10 +312,30 @@ The people list does not include any of these fields. } ], "carryMass": 1.2, - "carryCapacity": 14 + "carryCapacity": 14, + "hasAvatar": false, + "hasFullBody": false } ``` +### `GET /api/schools/{id}/people/{personId}/portrait` + +Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or +full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`; +missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type +is `image/png`. Opening the card does not generate; use POST when the player asks. + +### `POST /api/schools/{id}/people/{personId}/portrait` + +Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET. +Success is `201` with `{ "kind", "hasAvatar", "hasFullBody" }` and a `Location` header pointing +at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`. +Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files +land under `saves/{id}.portraits/` and survive until the school is deleted. + +`GET /api/status` includes `swarmUiConfigured` so the client can disable generate buttons without +trying POST first. + ### `GET /api/schools/{id}/staffing` Money, uncovered subjects, the applicant pool and current staff. Reads the **published** diff --git a/h-school.sln b/h-school.sln index f891b5a..22ab466 100644 --- a/h-school.sln +++ b/h-school.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai", "src\HSchool.A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai.Tests", "tests\HSchool.Ai.Tests\HSchool.Ai.Tests.csproj", "{C355307C-D7D3-48C7-9615-CB5FBCF83D49}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Server.Tests", "tests\HSchool.Server.Tests\HSchool.Server.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -241,6 +243,18 @@ Global {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.Build.0 = Release|Any CPU {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.ActiveCfg = Release|Any CPU {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -262,6 +276,7 @@ Global {85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {C21FB23F-F131-4651-8236-4F3E076B40BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C355307C-D7D3-48C7-9615-CB5FBCF83D49} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829} diff --git a/src/HSchool.AppHost/AppHost.cs b/src/HSchool.AppHost/AppHost.cs index 2904945..58f7738 100644 --- a/src/HSchool.AppHost/AppHost.cs +++ b/src/HSchool.AppHost/AppHost.cs @@ -15,7 +15,8 @@ if (headless) var saves = Path.Combine(Path.GetTempPath(), "h-school-tests", Guid.NewGuid().ToString("N")); server .WithEnvironment("Simulation__SavesDirectory", saves) - .WithEnvironment("HSchool__AllowSaveReload", "true"); + .WithEnvironment("HSchool__AllowSaveReload", "true") + .WithEnvironment("SwarmUi__BaseUrl", ""); } if (!headless) diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 4e9e476..3d8cb15 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -137,6 +137,17 @@ const ru = { peopleSiblings: 'Братья и сёстры', peoplePartners: 'Супруг(а)', + peopleTabOverview: 'Обзор', + peopleTabPortrait: 'Портрет', + peoplePortraitAvatar: 'Аватар', + peoplePortraitFull: 'В полный рост', + peoplePortraitGenerateAvatar: 'Сгенерировать аватар', + peoplePortraitGenerateFull: 'Сгенерировать в полный рост', + peoplePortraitGenerating: 'Генерация…', + peoplePortraitMissing: 'Ещё не сгенерировано.', + peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.', + peoplePortraitFailed: 'Не удалось сгенерировать портрет.', + modeOverview: 'Обзор', modeManage: 'Управление', staffAllocated: 'Выделено', @@ -357,6 +368,17 @@ const en: Messages = { peopleSiblings: 'Siblings', peoplePartners: 'Spouse', + peopleTabOverview: 'Overview', + peopleTabPortrait: 'Portrait', + peoplePortraitAvatar: 'Avatar', + peoplePortraitFull: 'Full body', + peoplePortraitGenerateAvatar: 'Generate avatar', + peoplePortraitGenerateFull: 'Generate full body', + peoplePortraitGenerating: 'Generating…', + peoplePortraitMissing: 'Not generated yet.', + peoplePortraitUnavailable: 'SwarmUI is not configured on the server.', + peoplePortraitFailed: 'Could not generate the portrait.', + modeOverview: 'Overview', modeManage: 'Management', staffAllocated: 'Allocated', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index a6e7376..d4e87e0 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -295,6 +295,8 @@ export interface PersonCard { readonly carried: readonly CarriedItem[]; readonly carryMass: number; readonly carryCapacity: number; + readonly hasAvatar: boolean; + readonly hasFullBody: boolean; } export interface WornItem { @@ -337,6 +339,42 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri ); } +export interface GameStatus { + readonly tick: number; + readonly tickRate: number; + readonly schools: number; + readonly maxSchools: number; + readonly connections: number; + readonly swarmUiConfigured: boolean; +} + +export async function fetchGameStatus(): Promise { + return request('/api/status'); +} + +export interface PortraitResult { + readonly kind: 'avatar' | 'full'; + readonly hasAvatar: boolean; + readonly hasFullBody: boolean; +} + +export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | 'full'): string { + const params = new URLSearchParams({ kind }); + return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`; +} + +export async function generatePortrait( + schoolId: number, + personId: string, + kind: 'avatar' | 'full', +): Promise { + const params = new URLSearchParams({ kind }); + return request( + `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`, + { method: 'POST' }, + ); +} + export interface DirectoryPerson { readonly id: string; readonly fullName: string; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 2bb77e8..b01fd75 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -581,6 +581,62 @@ body { font-size: 13px; } +.people__tabs { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.people__tab-panel { + min-width: 0; +} + +.people__card-header { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 8px; +} + +.people__card-titles { + min-width: 0; + flex: 1; +} + +.people__avatar { + width: 72px; + height: 72px; + flex-shrink: 0; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-raised); +} + +.people__portrait { + display: block; + max-width: 100%; + margin: 0 0 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-raised); +} + +.people__portrait--avatar { + width: 256px; + height: 256px; + object-fit: cover; +} + +.people__portrait--full { + width: min(100%, 384px); + height: auto; +} + +.people__portrait-panel .button { + margin-bottom: 16px; +} + .people__card-name { margin: 0 0 2px; font-size: 15px; diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts index a934bd0..b6cb366 100644 --- a/src/HSchool.Client/src/ui/applicantsDialog.ts +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -9,7 +9,8 @@ import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; import { Modal } from './modal.ts'; -import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { formatPersonPlace } from './personCard.ts'; +import { PersonCardHost } from './personCardHost.ts'; import { actionError, fillSelect, @@ -68,11 +69,13 @@ export class ApplicantsDialog { private dir: 'asc' | 'desc' = 'asc'; private cardToken = 0; private busy = false; + private readonly cardHost = new PersonCardHost(); private painted: PersonCard | null = null; constructor(private readonly options: ApplicantsDialogOptions) { this.staffing = options.staffing; this.selectedId = options.selectedId ?? null; + this.cardHost.attach(options.schoolId); this.error.hidden = true; this.ageMinInput.min = '0'; this.ageMaxInput.min = '0'; @@ -151,7 +154,7 @@ export class ApplicantsDialog { this.paintList(); if (this.selectedId !== null) { - this.appendHireIfNeeded(); + void this.openCard(this.selectedId); } } @@ -310,19 +313,26 @@ export class ApplicantsDialog { } private paintCard(card: PersonCard | null): void { - clear(this.card); this.painted = card; if (card === null) { + clear(this.card); this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') })); return; } - renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id)); - this.appendHireIfNeeded(); + this.cardHost.paint( + this.card, + card, + (id) => this.openRelative(id), + this.placeOf(card.id), + ); + this.appendHireIfNeeded(this.card); } - private appendHireIfNeeded(): void { - const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id); + private appendHireIfNeeded(parent: HTMLElement = this.card): void { + const personId = this.selectedId ?? this.painted?.id; + const applicant = + personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId); if (applicant === undefined) { return; } @@ -349,7 +359,7 @@ export class ApplicantsDialog { onClick: () => void this.hire(applicant.id), }), ); - this.card.append(actions); + parent.append(actions); } private openRelative(personId: string): void { diff --git a/src/HSchool.Client/src/ui/dom.ts b/src/HSchool.Client/src/ui/dom.ts index 9d9df4c..db7b5ae 100644 --- a/src/HSchool.Client/src/ui/dom.ts +++ b/src/HSchool.Client/src/ui/dom.ts @@ -8,6 +8,9 @@ interface ElementOptions { title?: string; type?: string; disabled?: boolean; + hidden?: boolean; + src?: string; + alt?: string; dataset?: Record; onClick?: (event: Event) => void; } @@ -24,6 +27,15 @@ export function el( if (options.title !== undefined) element.title = options.title; if (options.type !== undefined) element.setAttribute('type', options.type); if (options.disabled !== undefined) element.toggleAttribute('disabled', options.disabled); + if (options.hidden !== undefined) element.toggleAttribute('hidden', options.hidden); + if (options.src !== undefined && 'src' in element) { + (element as HTMLImageElement).src = options.src; + } + + if (options.alt !== undefined && 'alt' in element) { + (element as HTMLImageElement).alt = options.alt; + } + if (options.onClick !== undefined) element.addEventListener('click', options.onClick); for (const [key, value] of Object.entries(options.dataset ?? {})) { diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index b0ba4e9..aa529ed 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, fetchPerson, + fetchGameStatus, fetchStaffing, fetchTimetable, hireStaff, @@ -24,6 +25,14 @@ vi.mock('../net/api.ts', async (importOriginal) => { fetchStaffing: vi.fn(), fetchTimetable: vi.fn(), fetchPerson: vi.fn(), + fetchGameStatus: vi.fn().mockResolvedValue({ + tick: 0, + tickRate: 20, + schools: 1, + maxSchools: 6, + connections: 0, + swarmUiConfigured: false, + }), hireStaff: vi.fn(), assignSubject: vi.fn(), unassignSubject: vi.fn(), @@ -59,6 +68,8 @@ function personCard(): PersonCard { carried: [], carryMass: 0, carryCapacity: 0, + hasAvatar: false, + hasFullBody: false, }; } @@ -107,7 +118,17 @@ describe('ManagementPanel payroll cap', () => { vi.mocked(hireStaff).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue(staffing()); vi.mocked(fetchTimetable).mockResolvedValue(timetable()); - vi.mocked(fetchPerson).mockResolvedValue(personCard()); + vi.mocked(fetchPerson).mockImplementation((_schoolId, personId) => + Promise.resolve({ ...personCard(), id: personId }), + ); + vi.mocked(fetchGameStatus).mockResolvedValue({ + tick: 0, + tickRate: 20, + schools: 1, + maxSchools: 6, + connections: 0, + swarmUiConfigured: false, + }); vi.mocked(hireStaff).mockRejectedValue( new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000), ); @@ -147,10 +168,22 @@ describe('ManagementPanel payroll cap', () => { row.click(); await vi.waitFor(() => expect(fetchPerson).toHaveBeenCalled()); - const hire = [...dialog.querySelectorAll('button')].find((button) => button.textContent === t('staffHire')); - if (!(hire instanceof HTMLButtonElement)) { - throw new Error('hire button is missing'); - } + await vi.waitFor(() => + expect(dialog.querySelector('.people__card-name')?.textContent).toContain('Sidorova'), + ); + const hire = await vi.waitFor(() => { + const actions = dialog.querySelector('.staffing__actions'); + if (actions === null) { + throw new Error('staffing actions are missing'); + } + + const button = actions.querySelector('button'); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('hire button is missing'); + } + + return button; + }); hire.click(); const banner = dialog.querySelector('.staffing__error'); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 2d2094a..d7afd10 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -13,7 +13,8 @@ import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { ApplicantsDialog } from './applicantsDialog.ts'; import { clear, el } from './dom.ts'; -import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { formatPersonPlace } from './personCard.ts'; +import { PersonCardHost } from './personCardHost.ts'; import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; @@ -64,6 +65,7 @@ export class ManagementPanel { private locate: ((id: string) => string) | null = null; private painted: PersonCard | null = null; private poolDialog: ApplicantsDialog | null = null; + private readonly cardHost = new PersonCardHost(); constructor() { this.error.hidden = true; @@ -136,12 +138,13 @@ export class ManagementPanel { } this.timetableGrid.attach(schoolId); + this.cardHost.attach(schoolId); void this.reload(); } setLocate(locate: (id: string) => string): void { this.locate = locate; - this.relocate(); + this.cardHost.relocate((id) => this.placeOf(id)); } private async reload(): Promise { @@ -399,39 +402,33 @@ export class ManagementPanel { } private paintCard(card: PersonCard | null): void { - clear(this.card); this.painted = card; if (card === null) { + clear(this.card); this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') })); return; } - renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id)); - this.mountPersonTimetable(card); - this.appendActions(card.id); + this.cardHost.paint( + this.card, + card, + (id) => void this.openRelative(id), + this.placeOf(card.id), + (overview, painted) => { + this.mountPersonTimetable(overview, painted); + this.appendActions(overview, painted.id); + }, + ); } - private relocate(): void { - const line = this.card.querySelector('.people__card-place'); - if (!(line instanceof HTMLElement) || this.painted === null) { - return; - } - - line.textContent = this.placeOf(this.painted.id); - } - - private placeOf(id: string): string { - return this.locate?.(id) ?? formatPersonPlace('away'); - } - - private mountPersonTimetable(card: PersonCard): void { + private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void { const schoolId = this.schoolId; const query = personTimetableQuery(card); if (schoolId === null || query === null) { return; } - this.card.append(this.personTimetableTitle, this.personGrid.element); + overview.append(this.personTimetableTitle, this.personGrid.element); this.personGrid.attach(schoolId); const token = this.cardToken; void fetchTimetable(schoolId, getLocale(), query) @@ -451,6 +448,10 @@ export class ManagementPanel { }); } + private placeOf(id: string): string { + return this.locate?.(id) ?? formatPersonPlace('away'); + } + private async openRelative(personId: string): Promise { const staffing = this.staffing; if (staffing !== null && staffing.staff.some((row) => row.id === personId)) { @@ -466,7 +467,7 @@ export class ManagementPanel { await this.select(personId); } - private appendActions(personId: string): void { + private appendActions(parent: HTMLElement, personId: string): void { const staffing = this.staffing; if (staffing === null) { return; @@ -474,11 +475,11 @@ export class ManagementPanel { const member = staffing.staff.find((row) => row.id === personId); if (member !== undefined && member.position === TEACHER) { - this.appendSubjects(member); + this.appendSubjects(parent, member); } } - private appendSubjects(member: StaffMember): void { + private appendSubjects(parent: HTMLElement, member: StaffMember): void { const staffing = this.staffing; if (staffing === null) { return; @@ -530,7 +531,7 @@ export class ManagementPanel { ); } - this.card.append(actions); + parent.append(actions); } private async assign(personId: string): Promise { diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index e20ab8f..3dd9efc 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -2,7 +2,8 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; -import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts'; +import { formatPersonPlace, placement, roleLabels } from './personCard.ts'; +import { PersonCardHost } from './personCardHost.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ @@ -62,6 +63,7 @@ export class PeoplePanel { private cardToken = 0; private locate: ((id: string) => string) | null = null; private painted: PersonCard | null = null; + private readonly cardHost = new PersonCardHost(); constructor(private readonly options: PeoplePanelOptions) { this.ageMinInput.min = '0'; @@ -156,6 +158,7 @@ export class PeoplePanel { show(schoolId: number): void { this.schoolId = schoolId; + this.cardHost.attach(schoolId); this.sort = 'surname'; this.dir = 'asc'; this.page = 1; @@ -179,7 +182,7 @@ export class PeoplePanel { setLocate(locate: (id: string) => string): void { this.locate = locate; - this.relocate(); + this.cardHost.relocate((id) => this.placeOf(id)); } private onFilterChange(): void { @@ -361,38 +364,30 @@ export class PeoplePanel { } private paintCard(card: PersonCard | null): void { - clear(this.card); this.painted = card; if (card === null) { + clear(this.card); this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') })); return; } - renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id)); - this.mountPersonTimetable(card); + this.cardHost.paint( + this.card, + card, + (id) => void this.openCard(id), + this.placeOf(card.id), + (overview, painted) => this.mountPersonTimetable(overview, painted), + ); } - private relocate(): void { - const line = this.card.querySelector('.people__card-place'); - if (!(line instanceof HTMLElement) || this.painted === null) { - return; - } - - line.textContent = this.placeOf(this.painted.id); - } - - private placeOf(id: string): string { - return this.locate?.(id) ?? formatPersonPlace('away'); - } - - private mountPersonTimetable(card: PersonCard): void { + private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void { const schoolId = this.schoolId; const query = personTimetableQuery(card); if (schoolId === null || query === null) { return; } - this.card.append(this.personTimetableTitle, this.personGrid.element); + overview.append(this.personTimetableTitle, this.personGrid.element); this.personGrid.attach(schoolId); const token = this.cardToken; void fetchTimetable(schoolId, getLocale(), query) @@ -411,6 +406,10 @@ export class PeoplePanel { this.personGrid.setTable(null); }); } + + private placeOf(id: string): string { + return this.locate?.(id) ?? formatPersonPlace('away'); + } } function field(label: HTMLElement, control: HTMLElement): HTMLLabelElement { diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index f63ecae..ecb6cc8 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -1,11 +1,11 @@ /** * @vitest-environment happy-dom */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { PersonCard } from '../net/api.ts'; import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; -import { renderPersonCard } from './personCard.ts'; +import { renderPersonCard, type PersonCardHandlers } from './personCard.ts'; const initial = getLocale(); @@ -58,15 +58,29 @@ function card(overrides: Partial = {}): PersonCard { ], carryMass: 1.2, carryCapacity: 14, + hasAvatar: false, + hasFullBody: false, + ...overrides, + }; +} + +function handlers(overrides: Partial = {}): PersonCardHandlers { + return { + onRelative: () => {}, + schoolId: 1, + tab: 'overview', + onTabChange: () => {}, + portraitBusy: null, + portraitError: null, ...overrides, }; } describe('renderPersonCard', () => { - it('shows worn layers with colour and carried mass in the same scroll', () => { + it('shows worn layers with colour and carried mass on the overview tab', () => { setLocale('ru'); const root = document.createElement('div'); - renderPersonCard(root, card(), () => {}); + renderPersonCard(root, card(), handlers()); expect(root.textContent).toContain(t('peopleApparel')); expect(root.textContent).toContain('Верх'); @@ -74,7 +88,7 @@ describe('renderPersonCard', () => { expect(root.textContent).toContain(t('peopleCarry')); expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' })); expect(root.textContent).toContain('Учебник · Математика'); - expect(root.querySelector('.people__tabs')).toBeNull(); + expect(root.querySelector('.people__tabs')).not.toBeNull(); }); it('shows the server condition caption, not a band inferred from the number', () => { @@ -95,7 +109,7 @@ describe('renderPersonCard', () => { }, ], }), - () => {}, + handlers(), ); expect(root.textContent).toContain('целая'); @@ -104,30 +118,46 @@ describe('renderPersonCard', () => { expect((root.querySelector('.people__need-fill') as HTMLElement | null)?.style.width).toBe('10%'); }); - it('changes the caption when the payload label crosses a threshold', () => { - setLocale('ru'); + it('shows the avatar on overview only when hasAvatar is true', () => { const root = document.createElement('div'); - renderPersonCard(root, card(), () => {}); - expect(root.querySelector('.people__wear-label')?.textContent).toBe('целая'); + renderPersonCard(root, card({ hasAvatar: false }), handlers({ schoolId: 1 })); + expect(root.querySelector('.people__avatar')).toBeNull(); root.replaceChildren(); - renderPersonCard( - root, - card({ - worn: [ - { - defName: 'Shirt', - label: 'Рубашка', - color: 'White', - colorLabel: 'Белый', - layers: [{ defName: 'Top', label: 'Верх' }], - condition: 0.1, - conditionLabel: 'висит лохмотьями', - }, - ], - }), - () => {}, - ); - expect(root.querySelector('.people__wear-label')?.textContent).toBe('висит лохмотьями'); + renderPersonCard(root, card({ hasAvatar: true }), handlers({ schoolId: 1 })); + const image = root.querySelector('.people__avatar') as HTMLImageElement | null; + expect(image).not.toBeNull(); + expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar'); + }); + + it('does not mount apparel on the portrait tab', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card(), handlers({ tab: 'portrait' })); + + const panel = root.querySelector('.people__portrait-panel'); + expect(panel).not.toBeNull(); + expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar')); + expect(panel?.textContent).not.toContain(t('peopleApparel')); + }); + + it('disables generate while busy', () => { + setLocale('en'); + const root = document.createElement('div'); + renderPersonCard(root, card(), handlers({ tab: 'portrait', portraitBusy: 'avatar' })); + + const buttons = [...root.querySelectorAll('button')].filter((button) => button.textContent?.includes('Generating')); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((button) => button.disabled)).toBe(true); + }); + + it('calls onTabChange when a tab is clicked', () => { + const onTabChange = vi.fn(); + const root = document.createElement('div'); + renderPersonCard(root, card(), handlers({ onTabChange })); + + const portraitTab = [...root.querySelectorAll('button')].find((button) => button.textContent === t('peopleTabPortrait')); + portraitTab?.click(); + expect(onTabChange).toHaveBeenCalledWith('portrait'); }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index dcb4c2b..e997d12 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -1,4 +1,5 @@ import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts'; +import { portraitUrl } from '../net/api.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { el } from './dom.ts'; @@ -8,6 +9,24 @@ const ROLE_KEYS: Record = { parent: 'peopleRoleParent', }; +export type PersonCardTab = 'overview' | 'portrait'; + +export interface PersonCardHandlers { + readonly onRelative: (id: string) => void; + readonly place?: string; + readonly schoolId: number | null; + readonly tab: PersonCardTab; + readonly onTabChange: (tab: PersonCardTab) => void; + readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void; + readonly portraitBusy?: 'avatar' | 'full' | null; + readonly portraitError?: string | null; + readonly swarmConfigured?: boolean; +} + +export interface PersonCardMount { + readonly overviewPane: HTMLElement; +} + export function roleLabels(roles: readonly string[]): string { return roles .map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role)) @@ -30,15 +49,57 @@ export function placement(person: Pick void, - place?: string, -): void { - parent.append( + handlers: PersonCardHandlers, +): PersonCardMount { + const tabs = el('div', { class: 'people__tabs' }); + const overviewTab = tabButton(t('peopleTabOverview'), handlers.tab === 'overview', () => handlers.onTabChange('overview')); + const portraitTab = tabButton(t('peopleTabPortrait'), handlers.tab === 'portrait', () => handlers.onTabChange('portrait')); + tabs.append(overviewTab, portraitTab); + + const overviewPane = el('div', { class: 'people__tab-panel' }); + const portraitPane = el('div', { class: 'people__tab-panel people__portrait-panel', hidden: handlers.tab !== 'portrait' }); + if (handlers.tab !== 'overview') { + overviewPane.hidden = true; + } + + mountOverview(overviewPane, card, handlers); + mountPortrait(portraitPane, card, handlers); + + parent.append(tabs, overviewPane, portraitPane); + return { overviewPane }; +} + +function tabButton(label: string, active: boolean, onClick: () => void): HTMLButtonElement { + return el('button', { + class: `panel__tab${active ? ' panel__tab--active' : ''}`, + type: 'button', + text: label, + onClick, + }); +} + +function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { + const header = el('div', { class: 'people__card-header' }); + if (card.hasAvatar && handlers.schoolId !== null) { + header.append( + el('img', { + class: 'people__avatar', + alt: card.fullName, + src: portraitUrl(handlers.schoolId, card.id, 'avatar'), + }), + ); + } + + const titles = el('div', { class: 'people__card-titles' }); + titles.append( el('h3', { class: 'people__card-name', text: card.fullName }), el('p', { class: 'people__card-meta', text: cardMeta(card) }), ); - if (place !== undefined && place.length > 0) { - parent.append(el('p', { class: 'people__card-place', text: place })); + header.append(titles); + parent.append(header); + + if (handlers.place !== undefined && handlers.place.length > 0) { + parent.append(el('p', { class: 'people__card-place', text: handlers.place })); } if (card.activityLabel !== null && card.activityLabel.length > 0) { parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel })); @@ -51,15 +112,62 @@ export function renderPersonCard( appendCarry(parent, card); const family = section(t('peopleFamily')); - appendRelatives(family, t('peopleParents'), card.family.parents, onRelative); - appendRelatives(family, t('peopleChildren'), card.family.children, onRelative); - appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative); - appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative); + appendRelatives(family, t('peopleParents'), card.family.parents, handlers.onRelative); + appendRelatives(family, t('peopleChildren'), card.family.children, handlers.onRelative); + appendRelatives(family, t('peopleSiblings'), card.family.siblings, handlers.onRelative); + appendRelatives(family, t('peoplePartners'), card.family.partners, handlers.onRelative); if (family.childElementCount > 1) { parent.append(family); } } +function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { + parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') })); + parent.append(portraitPreview(card, handlers, 'avatar')); + parent.append( + el('button', { + class: 'button button--small', + type: 'button', + text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), + disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false, + onClick: () => handlers.onGeneratePortrait?.('avatar'), + }), + ); + + parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') })); + parent.append(portraitPreview(card, handlers, 'full')); + parent.append( + el('button', { + class: 'button button--small', + type: 'button', + text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), + disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false, + onClick: () => handlers.onGeneratePortrait?.('full'), + }), + ); + + if (handlers.swarmConfigured === false) { + parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); + } + + if ((handlers.portraitError?.length ?? 0) > 0) { + parent.append(el('p', { class: 'panel__error', text: handlers.portraitError! })); + } +} + +function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement { + const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; + if (hasImage && handlers.schoolId !== null) { + return el('img', { + class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full', + alt: card.fullName, + src: portraitUrl(handlers.schoolId, card.id, kind), + }); + } + + return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); +} + function cardMeta(card: PersonCard): string { const bits = [ roleLabels(card.roles), @@ -119,36 +227,34 @@ function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void { return; } - const list = el('div', { class: 'people__garments' }); + const block = section(t('peopleApparel')); + const garments = el('div', { class: 'people__garments' }); for (const row of rows) { const layers = row.layers.map((layer) => layer.label).join(', '); const color = row.colorLabel ?? row.color; const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label; + const garment = el('div', { class: 'people__garment' }); + garment.append(el('span', { text: layers.length > 0 ? layers : value })); + if (layers.length > 0) { + garment.append(el('span', { text: value })); + } + const share = Math.max(0, Math.min(1, row.condition)); const fill = el('span', { class: 'people__need-fill' }); fill.style.width = `${Math.round(share * 100)}%`; - fill.classList.toggle('people__need-fill--low', share < 0.25); - list.append( + garment.append( el( 'div', - { class: 'people__garment' }, - el( - 'div', - { class: 'people__pair' }, - el('dt', { text: layers.length > 0 ? layers : row.label }), - el('dd', { text: value }), - ), - el( - 'div', - { class: 'people__wear' }, - el('span', { class: 'people__wear-label', text: row.conditionLabel }), - el('span', { class: 'people__need-track' }, fill), - ), + { class: 'people__wear' }, + el('span', { class: 'people__wear-label', text: row.conditionLabel }), + el('span', { class: 'people__need-track' }, fill), ), ); + garments.append(garment); } - parent.append(section(t('peopleApparel')), list); + block.append(garments); + parent.append(block); } function appendCarry(parent: HTMLElement, card: PersonCard): void { diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts new file mode 100644 index 0000000..9abddbb --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -0,0 +1,138 @@ +import { ApiError, fetchGameStatus, fetchPerson, generatePortrait, type PersonCard } from '../net/api.ts'; +import { getLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { clear } from './dom.ts'; +import { + renderPersonCard, + type PersonCardHandlers, + type PersonCardMount, + type PersonCardTab, +} from './personCard.ts'; + +/** Shared card tab state, portrait generation and Swarm availability for people-like panels. */ +export class PersonCardHost { + private tab: PersonCardTab = 'overview'; + private portraitBusy: 'avatar' | 'full' | null = null; + private portraitError: string | null = null; + private swarmConfigured = false; + private schoolId: number | null = null; + private painted: PersonCard | null = null; + private mount: PersonCardMount | null = null; + private container: HTMLElement | null = null; + private onRelative: (id: string) => void = () => {}; + private placeOf: (id: string) => string = () => ''; + private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void; + + attach(schoolId: number): void { + this.schoolId = schoolId; + void fetchGameStatus() + .then((status) => { + this.swarmConfigured = status.swarmUiConfigured; + }) + .catch(() => { + this.swarmConfigured = false; + }); + } + + detach(): void { + this.schoolId = null; + this.painted = null; + this.mount = null; + this.container = null; + this.onOverviewMounted = undefined; + this.tab = 'overview'; + this.portraitBusy = null; + this.portraitError = null; + } + + paint( + container: HTMLElement, + card: PersonCard | null, + onRelative: (id: string) => void, + placeOf: (id: string) => string, + onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void, + ): void { + this.container = container; + this.onRelative = onRelative; + this.placeOf = placeOf; + this.onOverviewMounted = onOverviewMounted; + clear(container); + this.painted = card; + this.mount = null; + + if (card === null) { + return; + } + + this.repaint(container, card); + } + + private refreshPainted(): void { + if (this.painted !== null && this.container !== null) { + this.repaint(this.container, this.painted); + } + } + + private repaint(container: HTMLElement, card: PersonCard): void { + clear(container); + this.mount = renderPersonCard(container, card, this.handlers(this.onRelative, this.placeOf(card.id))); + this.onOverviewMounted?.(this.mount.overviewPane, card); + } + + private handlers(onRelative: (id: string) => void, place: string): PersonCardHandlers { + return { + onRelative, + place, + schoolId: this.schoolId, + tab: this.tab, + onTabChange: (tab) => { + this.tab = tab; + this.refreshPainted(); + }, + onGeneratePortrait: (kind) => void this.generate(kind), + portraitBusy: this.portraitBusy, + portraitError: this.portraitError, + swarmConfigured: this.swarmConfigured, + }; + } + + private async generate(kind: 'avatar' | 'full'): Promise { + const schoolId = this.schoolId; + const personId = this.painted?.id; + if (schoolId === null || personId === undefined || this.portraitBusy !== null) { + return; + } + + this.portraitBusy = kind; + this.portraitError = null; + this.refreshPainted(); + + try { + const result = await generatePortrait(schoolId, personId, kind); + const card = await fetchPerson(schoolId, personId, getLocale()); + this.painted = { + ...card, + hasAvatar: result.hasAvatar, + hasFullBody: result.hasFullBody, + }; + } catch (error) { + this.portraitError = + error instanceof ApiError && error.code === 'swarmui-not-configured' + ? t('peoplePortraitUnavailable') + : t('peoplePortraitFailed'); + } finally { + this.portraitBusy = null; + this.refreshPainted(); + } + } + + relocate(placeOf: (id: string) => string): void { + this.placeOf = placeOf; + const line = this.container?.querySelector('.people__card-place'); + if (!(line instanceof HTMLElement) || this.painted === null) { + return; + } + + line.textContent = placeOf(this.painted.id); + } +} diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index dd378a5..5d9a3aa 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -65,7 +65,9 @@ internal sealed record PersonCardResponse( IReadOnlyList Worn, IReadOnlyList Carried, float CarryMass, - float CarryCapacity); + float CarryCapacity, + bool HasAvatar = false, + bool HasFullBody = false); internal sealed record WornItemResponse( string DefName, diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 3e31eb9..9d8b58e 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -174,6 +174,7 @@ internal static class SchoolEndpoints string personId, string? lang, GameCommandQueue commands, + PortraitService portraits, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64) @@ -187,13 +188,95 @@ internal static class SchoolEndpoints var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); return outcome.Error switch { - PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card), + PersonLookupError.None when outcome.Card is not null => + Results.Ok(portraits.WithPortraitFlags(id, outcome.Card)), PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), }; }) .WithName("GetSchoolPerson"); + schools.MapGet("/{id:int}/people/{personId}/portrait", async ( + int id, + string personId, + string? kind, + PortraitService portraits, + SchoolStore store, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid."); + } + + if (!PortraitKindParser.TryParse(kind, out var portraitKind)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full."); + } + + var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken); + if (lookup == PersonLookupError.UnknownPerson) + { + return Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."); + } + + if (lookup != PersonLookupError.None) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + var path = store.PortraitPath(id, personId, portraitKind); + if (!File.Exists(path)) + { + return Problem(StatusCodes.Status404NotFound, "portrait-missing", "That portrait has not been generated yet."); + } + + return Results.File(path, "image/png"); + }) + .WithName("GetSchoolPersonPortrait"); + + schools.MapPost("/{id:int}/people/{personId}/portrait", async ( + int id, + string personId, + string? kind, + PortraitService portraits, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid."); + } + + if (!PortraitKindParser.TryParse(kind, out var portraitKind)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full."); + } + + if (!portraits.IsGenerationEnabled) + { + return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."); + } + + var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken); + return result.Outcome switch + { + PortraitGenerationOutcome.Succeeded => Results.Created( + $"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}", + new PortraitResponse( + portraitKind == PortraitKind.Avatar ? "avatar" : "full", + result.HasAvatar, + result.HasFullBody)), + 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"); + schools.MapGet("/{id:int}/staffing", ( int id, string? lang, diff --git a/src/HSchool.Server/Game/PortraitKind.cs b/src/HSchool.Server/Game/PortraitKind.cs new file mode 100644 index 0000000..2a0de32 --- /dev/null +++ b/src/HSchool.Server/Game/PortraitKind.cs @@ -0,0 +1,28 @@ +namespace HSchool.Server.Game; + +internal enum PortraitKind +{ + Avatar, + Full, +} + +internal static class PortraitKindParser +{ + public static bool TryParse(string? value, out PortraitKind kind) + { + if (string.Equals(value, "avatar", StringComparison.OrdinalIgnoreCase)) + { + kind = PortraitKind.Avatar; + return true; + } + + if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase)) + { + kind = PortraitKind.Full; + return true; + } + + kind = default; + return false; + } +} diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs new file mode 100644 index 0000000..c31236d --- /dev/null +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -0,0 +1,48 @@ +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +/// Turns a person card into a Flux-style English prompt for SwarmUI. +internal static class PortraitPromptBuilder +{ + public static (string Positive, string Negative) Build(PersonCardResponse card, SwarmUiSettings settings, PortraitKind kind) + { + var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; + var parts = new List(); + + if (!string.IsNullOrWhiteSpace(settings.Positive)) + { + parts.Add(settings.Positive.Trim()); + } + + if (!string.IsNullOrWhiteSpace(preset.Positive)) + { + parts.Add(preset.Positive.Trim()); + } + + parts.Add(card.Female ? "A young woman" : "A young man"); + parts.Add($"age {card.Age}"); + + foreach (var row in card.Body) + { + parts.Add($"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}"); + } + + foreach (var item in card.Worn) + { + var color = item.ColorLabel ?? item.Color; + if (!string.IsNullOrWhiteSpace(color)) + { + parts.Add($"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}"); + } + else + { + parts.Add($"wearing {item.Label.ToLowerInvariant()}"); + } + } + + var positive = string.Join(", ", parts.Where(part => part.Length > 0)); + var negative = settings.Negative?.Trim() ?? string.Empty; + return (positive, negative); + } +} diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs new file mode 100644 index 0000000..55fea08 --- /dev/null +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -0,0 +1,138 @@ +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +internal sealed class PortraitService( + SchoolStore store, + SwarmUiClient swarm, + SwarmUiSettings settings, + GameCommandQueue commands, + ILogger logger) +{ + private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5); + + /// English labels for Swarm prompts, independent of the UI locale. + private const string PromptLocale = "en"; + + public bool IsGenerationEnabled => swarm.IsConfigured; + + public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) => + store.PortraitFlags(schoolId, personId); + + public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card) + { + var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id); + return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody }; + } + + public async Task EnsurePersonAsync( + int schoolId, + string personId, + CancellationToken cancellationToken) + { + var command = new GameCommand.GetPerson( + schoolId, + personId, + PromptLocale, + NewCompletion()); + commands.Enqueue(command); + + var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken); + return outcome.Error; + } + + public async Task GenerateAsync( + int schoolId, + string personId, + PortraitKind kind, + CancellationToken cancellationToken) + { + if (!swarm.IsConfigured) + { + return PortraitGenerationResult.NotConfigured; + } + + var command = new GameCommand.GetPerson( + schoolId, + personId, + PromptLocale, + NewCompletion()); + commands.Enqueue(command); + + var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken); + if (outcome.Error == PersonLookupError.UnknownPerson) + { + return PortraitGenerationResult.UnknownPerson; + } + + if (outcome.Error != PersonLookupError.None || outcome.Card is null) + { + return PortraitGenerationResult.UnknownSchool; + } + + var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind); + + try + { + var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken); + store.SavePortrait(schoolId, personId, kind, bytes); + var flags = Flags(schoolId, personId); + return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasFullBody); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId); + return PortraitGenerationResult.TimedOut; + } + catch (HttpRequestException ex) + { + logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId); + return PortraitGenerationResult.Unavailable; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId); + return PortraitGenerationResult.Unavailable; + } + } + + private static TaskCompletionSource NewCompletion() => + new(TaskCreationOptions.RunContinuationsAsynchronously); +} + +internal enum PortraitGenerationOutcome +{ + Succeeded, + UnknownSchool, + UnknownPerson, + NotConfigured, + Unavailable, + TimedOut, +} + +internal sealed record PortraitGenerationResult( + PortraitGenerationOutcome Outcome, + PortraitKind Kind, + bool HasAvatar, + bool HasFullBody) +{ + public static PortraitGenerationResult UnknownSchool { get; } = + new(PortraitGenerationOutcome.UnknownSchool, default, false, false); + + public static PortraitGenerationResult UnknownPerson { get; } = + new(PortraitGenerationOutcome.UnknownPerson, default, false, false); + + public static PortraitGenerationResult NotConfigured { get; } = + new(PortraitGenerationOutcome.NotConfigured, default, false, false); + + public static PortraitGenerationResult Unavailable { get; } = + new(PortraitGenerationOutcome.Unavailable, default, false, false); + + public static PortraitGenerationResult TimedOut { get; } = + new(PortraitGenerationOutcome.TimedOut, default, false, false); + + public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) => + new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody); +} + +internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody); diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index c247ce5..6c816bf 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -260,6 +260,55 @@ internal sealed class SchoolStore { File.Delete(timetable); } + + var portraits = PortraitsDirectory(id); + if (Directory.Exists(portraits)) + { + Directory.Delete(portraits, recursive: true); + } + } + + public bool HasPortrait(int schoolId, string personId, PortraitKind kind) => + File.Exists(PortraitPath(schoolId, personId, kind)); + + public (bool HasAvatar, bool HasFullBody) PortraitFlags(int schoolId, string personId) + { + var directory = PortraitsDirectory(schoolId); + if (!Directory.Exists(directory)) + { + return (false, false); + } + + return (HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Full)); + } + + public string PortraitPath(int schoolId, string personId, PortraitKind kind) + { + var safeId = SanitizePersonId(personId); + var suffix = kind == PortraitKind.Avatar ? "avatar" : "full"; + return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png"); + } + + public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan png) + { + var path = PortraitPath(schoolId, personId, kind); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temp = path + ".tmp"; + File.WriteAllBytes(temp, png); + File.Move(temp, path, overwrite: true); + } + + private static string SanitizePersonId(string personId) + { + foreach (var ch in personId) + { + if (ch is not (>= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '-' or '_')) + { + throw new ArgumentException("The person id contains invalid characters.", nameof(personId)); + } + } + + return personId; } public RosterDocument? TryReadPeople(int id) @@ -319,6 +368,8 @@ internal sealed class SchoolStore private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json"); + private string PortraitsDirectory(int id) => Path.Combine(DirectoryPath, $"{id}.portraits"); + private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName); private static void WriteAtomic(string path, T value, JsonSerializerOptions? options = null) diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs new file mode 100644 index 0000000..1c7c9b9 --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -0,0 +1,180 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace HSchool.Server.Game; + +internal sealed class SwarmUiClient +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient _http; + private readonly SwarmUiOptions _options; + private readonly ILogger _logger; + private string? _sessionId; + + public SwarmUiClient(HttpClient http, IOptions options, ILogger logger) + { + _http = http; + _options = options.Value; + _logger = logger; + } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl); + + public async Task GenerateAsync( + string prompt, + string negativePrompt, + SwarmUiSettings settings, + PortraitKind kind, + CancellationToken cancellationToken) + { + if (!IsConfigured) + { + throw new InvalidOperationException("SwarmUI is not configured."); + } + + return await RunWithSessionAsync(async () => + { + var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody; + var body = new Dictionary + { + ["session_id"] = _sessionId, + ["images"] = 1, + ["donotsave"] = true, + ["prompt"] = prompt, + ["negativeprompt"] = negativePrompt, + ["model"] = settings.Model, + ["steps"] = settings.Steps, + ["cfgscale"] = settings.CfgScale, + ["width"] = preset.Width, + ["height"] = preset.Height, + ["seed"] = settings.Seed, + }; + + if (!string.IsNullOrWhiteSpace(settings.Sampler)) + { + body["sampler"] = settings.Sampler; + } + + if (settings.ClipSkip > 0) + { + body["clipskip"] = settings.ClipSkip; + } + + using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + using var response = await _http.PostAsync("/API/GenerateText2Image", content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + if (root.TryGetProperty("error_id", out var errorId) + && errorId.GetString() == "invalid_session_id") + { + throw new SwarmUiSessionInvalidException(); + } + + if (root.TryGetProperty("error", out var error)) + { + throw new InvalidOperationException($"SwarmUI error: {error.GetString()}"); + } + + if (!root.TryGetProperty("images", out var images) || images.GetArrayLength() == 0) + { + throw new InvalidOperationException("SwarmUI returned no images."); + } + + var first = images[0].GetString() ?? throw new InvalidOperationException("SwarmUI image entry was empty."); + return await ReadImageAsync(first, cancellationToken); + }, cancellationToken); + } + + private async Task ReadImageAsync(string reference, CancellationToken cancellationToken) + { + if (reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var comma = reference.IndexOf(',', StringComparison.Ordinal); + if (comma < 0) + { + throw new InvalidOperationException("Malformed data URL from SwarmUI."); + } + + return Convert.FromBase64String(reference[(comma + 1)..]); + } + + var path = reference.StartsWith('/') ? reference : $"/{reference}"; + using var response = await _http.GetAsync(path, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + private async Task RunWithSessionAsync(Func> call, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_sessionId)) + { + await RefreshSessionAsync(cancellationToken); + } + + try + { + return await call(); + } + catch (SwarmUiSessionInvalidException) + { + await RefreshSessionAsync(cancellationToken); + return await call(); + } + } + + private async Task RefreshSessionAsync(CancellationToken cancellationToken) + { + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + using var response = await _http.PostAsync("/API/GetNewSession", content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(json); + _sessionId = document.RootElement.GetProperty("session_id").GetString() + ?? throw new InvalidOperationException("SwarmUI did not return a session_id."); + _logger.LogDebug("SwarmUI session refreshed."); + } + + internal sealed class SwarmUiSessionInvalidException : Exception; +} + +internal static class SwarmUiClientRegistration +{ + public static IServiceCollection AddSwarmUi(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(SwarmUiOptions.SectionName)) + .Validate(options => options.TimeoutSeconds is > 0 and <= 3600, "SwarmUi:TimeoutSeconds must be between 1 and 3600.") + .ValidateOnStart(); + + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + if (!string.IsNullOrWhiteSpace(options.BaseUrl)) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + } + + if (!string.IsNullOrWhiteSpace(options.Authorization)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.Authorization); + } + }); + + return services; + } +} diff --git a/src/HSchool.Server/Game/SwarmUiSettings.cs b/src/HSchool.Server/Game/SwarmUiSettings.cs new file mode 100644 index 0000000..6d250c2 --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiSettings.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace HSchool.Server.Game; + +/// Generation defaults and prompt templates loaded from swarmui.json next to the server. +internal sealed class SwarmUiSettings +{ + public string Model { get; init; } = ""; + + public int Steps { get; init; } = 8; + + public double CfgScale { get; init; } = 1; + + public int ClipSkip { get; init; } = 1; + + public string Sampler { get; init; } = "euler"; + + public long Seed { get; init; } = -1; + + public string Positive { get; init; } = ""; + + public string Negative { get; init; } = ""; + + public SwarmUiPreset Avatar { get; init; } = new(); + + public SwarmUiPreset FullBody { get; init; } = new(); + + internal sealed class SwarmUiPreset + { + public int Width { get; init; } = 512; + + public int Height { get; init; } = 512; + + public string Positive { get; init; } = ""; + } + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger) + { + var path = Path.Combine(environment.ContentRootPath, "swarmui.json"); + if (!File.Exists(path)) + { + logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path); + return new SwarmUiSettings(); + } + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, Json) ?? new SwarmUiSettings(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path); + return new SwarmUiSettings(); + } + } +} diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj index a6deeba..725dca6 100644 --- a/src/HSchool.Server/HSchool.Server.csproj +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -22,6 +22,14 @@ PreserveNewest PreserveNewest + + PreserveNewest + PreserveNewest + + + + + diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index e5ee61a..0f4b74d 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -1,9 +1,11 @@ using System.Net.WebSockets; +using HSchool.Server; using HSchool.Server.Api; using HSchool.Server.Game; using HSchool.Server.Net; using HSchool.Simulation; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); @@ -34,6 +36,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddSwarmUi(builder.Configuration); +builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService(), sp.GetRequiredService().CreateLogger("SwarmUiSettings"))); +builder.Services.AddSingleton(); builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); @@ -55,10 +60,16 @@ app.MapSchoolEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); -app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => +app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions swarm) => { var state = loop.SchoolsState; - return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count); + return new GameStatusResponse( + loop.CurrentTick, + loop.Options.TickRate, + state.Schools.Count, + state.MaxSchools, + clients.Count, + !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl)); }) .WithName("GetGameStatus"); @@ -102,7 +113,13 @@ app.UseFileServer(); app.Run(); /// Loop health for dashboards and integration tests. -internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections); +internal sealed record GameStatusResponse( + uint Tick, + int TickRate, + int Schools, + int MaxSchools, + int Connections, + bool SwarmUiConfigured); /// Exposed so WebApplicationFactory-style tests can reference the entry point. public partial class Program; diff --git a/src/HSchool.Server/SwarmUiOptions.cs b/src/HSchool.Server/SwarmUiOptions.cs new file mode 100644 index 0000000..8868d30 --- /dev/null +++ b/src/HSchool.Server/SwarmUiOptions.cs @@ -0,0 +1,14 @@ +namespace HSchool.Server; + +/// Connection to a local SwarmUI instance. Empty disables generation. +internal sealed class SwarmUiOptions +{ + public const string SectionName = "SwarmUi"; + + public string BaseUrl { get; set; } = ""; + + /// Optional bearer token when SwarmUI requires authorization. + public string Authorization { get; set; } = ""; + + public int TimeoutSeconds { get; set; } = 180; +} diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index fed9c80..cbb9ff0 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -6,6 +6,11 @@ } }, "AllowedHosts": "*", + "SwarmUi": { + "BaseUrl": "http://127.0.0.1:7801", + "Authorization": "", + "TimeoutSeconds": 180 + }, "Simulation": { "TickRate": 20, "MaxSchools": 6, diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json new file mode 100644 index 0000000..4926c0f --- /dev/null +++ b/src/HSchool.Server/swarmui.json @@ -0,0 +1,20 @@ +{ + "model": "pornmasterFlux2Klein_v4TurboFp8.safetensors", + "steps": 8, + "cfgScale": 1, + "clipSkip": 1, + "sampler": "euler", + "seed": -1, + "positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.", + "negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo", + "avatar": { + "width": 512, + "height": 512, + "positive": "Head and shoulders portrait, facing the camera, upper body visible." + }, + "fullBody": { + "width": 768, + "height": 1024, + "positive": "Full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." + } +} diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs new file mode 100644 index 0000000..f052e2b --- /dev/null +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class PortraitApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetPortrait_WithoutFile_Returns404() + { + 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); + + using var response = await client.GetAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("portrait-missing", await SchoolApiTests.ProblemCodeAsync(response)); + } + + [Fact] + public async Task GetPortrait_WithFile_ReturnsPngAndCardFlags() + { + 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); + var directory = await SavesDirectoryAsync(client); + var portraits = Path.Combine(directory, $"{school.Id}.portraits"); + Directory.CreateDirectory(portraits); + var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + await File.WriteAllBytesAsync(Path.Combine(portraits, $"{personId}.avatar.png"), png, TestContext.Current.CancellationToken); + + using var image = await client.GetAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, image.StatusCode); + Assert.Equal("image/png", image.Content.Headers.ContentType?.MediaType); + + var card = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(card); + Assert.True(card!.HasAvatar); + Assert.False(card.HasFullBody); + } + + [Fact] + public async Task PostPortrait_WhenSwarmNotConfigured_Returns503() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Swarm выкл", Start); + var personId = await FirstPersonIdAsync(client, school.Id); + + using var response = await client.PostAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar", + null, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response)); + } + + [Fact] + public async Task DeleteSchool_RemovesPortraitDirectory() + { + 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); + var directory = await SavesDirectoryAsync(client); + var portraits = Path.Combine(directory, $"{school.Id}.portraits"); + Directory.CreateDirectory(portraits); + await File.WriteAllBytesAsync(Path.Combine(portraits, $"{personId}.avatar.png"), [1, 2, 3], TestContext.Current.CancellationToken); + + using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + Assert.False(Directory.Exists(portraits)); + } + + private static async Task FirstPersonIdAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?pageSize=1", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + Assert.NotEmpty(page!.People); + return page.People[0].Id; + } + + private static async Task SavesDirectoryAsync(HttpClient client) + { + var payload = await client.GetFromJsonAsync( + "/api/dev/saves-directory", + TestContext.Current.CancellationToken); + return payload!.Path; + } + + private sealed record PeopleListResponse(int Total, IReadOnlyList People); + + private sealed record PersonListItem(string Id); + + private sealed record PersonCardResponse(string Id, bool HasAvatar, bool HasFullBody); + + private sealed record SavesDirectoryResponse(string Path); +} diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index d2cbb4d..8b05060 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -617,7 +617,7 @@ public class SchoolApiTests(AppHostFixture fixture) seed is null ? (object)new { name, startDate } : new { name, startDate, seed }, TestContext.Current.CancellationToken); - private static async Task ProblemCodeAsync(HttpResponseMessage response) + internal static async Task ProblemCodeAsync(HttpResponseMessage response) { var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); return problem?.Code; diff --git a/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj new file mode 100644 index 0000000..a7ed373 --- /dev/null +++ b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj @@ -0,0 +1,24 @@ + + + + HSchool.Server.Tests + true + Exe + + + + + + + + + + + + + + + + + + diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs new file mode 100644 index 0000000..7e852b7 --- /dev/null +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -0,0 +1,72 @@ +using HSchool.Server.Api; +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class PortraitPromptBuilderTests +{ + [Fact] + public void Build_IncludesAgeHairAndWornClothing() + { + var settings = new SwarmUiSettings + { + Positive = "School photo.", + Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, + }; + + var card = SampleCard(); + var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar); + + Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("shirt", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("white", positive, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Head and shoulders.", positive, StringComparison.Ordinal); + } + + [Fact] + public void Build_FullBody_AddsFullBodyPreset() + { + var settings = new SwarmUiSettings + { + Positive = "School photo.", + Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." }, + FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." }, + }; + + var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar); + var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full); + + Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal); + Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal); + Assert.Contains("Standing full body.", fullPositive, StringComparison.Ordinal); + } + + private static PersonCardResponse SampleCard() => + new( + "f0.c0", + "Maria Ivanova", + "Ivanova", + "Maria", + "", + true, + 12, + new DateTime(2000, 3, 14, 0, 0, 0, DateTimeKind.Utc), + ["student"], + 5, + "A", + "class-1", + null, + null, + [new LabeledStatResponse("HairColor", "Hair colour", "Black")], + [], + [], + [], + null, + null, + new PersonFamilyResponse([], [], [], []), + [new WornItemResponse("Shirt", "Shirt", "White", "White", [], 1f, "new")], + [], + 0f, + 0f); +} diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs new file mode 100644 index 0000000..5145a1a --- /dev/null +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using HSchool.Server.Game; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace HSchool.Server.Tests; + +public class SwarmUiClientTests +{ + [Fact] + public async Task GenerateAsync_UsesSessionAndReturnsImageBytes() + { + var handler = new FakeHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") }; + var client = new SwarmUiClient( + http, + Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), + NullLogger.Instance); + + var settings = new SwarmUiSettings + { + Model = "model.safetensors", + Steps = 8, + CfgScale = 1, + Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 }, + }; + + var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None); + + Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4)); + Assert.Contains("/API/GetNewSession", handler.Requests[0]); + Assert.Contains("/API/GenerateText2Image", handler.Requests[1]); + } + + private sealed class FakeHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!.AbsolutePath); + + if (request.RequestUri.AbsolutePath.Contains("GetNewSession", StringComparison.Ordinal)) + { + var session = JsonSerializer.Serialize(new { session_id = "sess-1" }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(session, Encoding.UTF8, "application/json"), + }); + } + + if (request.RequestUri.AbsolutePath.Contains("GenerateText2Image", StringComparison.Ordinal)) + { + var payload = JsonSerializer.Serialize(new { images = new[] { "data:image/png;base64,iVBORw0KGgo=" } }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +}