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)); + } + } +}