diff --git a/docs/design/session.md b/docs/design/session.md index 823c17b..b930af4 100644 --- a/docs/design/session.md +++ b/docs/design/session.md @@ -128,6 +128,7 @@ Welcome не трогаем, версию не бампим. Глобальны - пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение); - удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`; +- генерировать портреты — **можно**: пресеты лежат на школе, модель одна и та же; - видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да. Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько diff --git a/docs/phases/39-school-owners.md b/docs/phases/39-school-owners.md index 541e896..757e572 100644 --- a/docs/phases/39-school-owners.md +++ b/docs/phases/39-school-owners.md @@ -21,7 +21,8 @@ `others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine` - [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый) - [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный. - `OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят + `OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят. + **Исключение:** `POST .../portrait` — гость генерирует теми же пресетами школы; это не управление - [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет - [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск). Карта и люди остаются diff --git a/docs/protocol.md b/docs/protocol.md index 9e56979..1df2394 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -164,7 +164,8 @@ Body: "map": null, "countryId": "Russia", "nativeLanguage": null, - "seed": null + "seed": null, + "portraitSettings": null } ``` @@ -178,6 +179,10 @@ the same catalog. Omit `map` (or send `null`) to use that pack set's default lay one from the school seed. An id that is not in the country returns `400` `unknown-native-language`. `seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and the server rolls one. Existing saves keep the seed already stored in the people file. +`portraitSettings` is the SwarmUI preset file for **this** school (same shape as +`GET /api/settings/swarmui`). Omit it (or send `null`) to copy the server template at create. +Invalid presets return `400` `invalid-portrait-settings`. Generation later uses this copy, not +the global file, so a guest watching the school draws with the same model. | Status | Meaning | | --- | --- | @@ -191,6 +196,7 @@ the server rolls one. Existing saves keep the seed already stored in the people | `400` `invalid-catalog` | The selected packs could not be loaded. | | `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. | | `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. | +| `400` `invalid-portrait-settings` | `portraitSettings` failed validation (empty presets, bad age rule, out of range). | | `409` `school-limit-reached` | `maxSchools` schools already exist. | Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on. @@ -470,14 +476,15 @@ so the client can disable generate buttons and show reachability without trying ### `GET /api/settings/swarmui` -Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler, -LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets. -Portrait generation resolves the preset from the person's age before building the prompt. +Returns the **default** SwarmUI preset template (`swarmui.json`): named presets (model, steps, +sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies +this into its save as `portraitSettings`. Living schools generate from that copy, not from this +file. ### `PUT /api/settings/swarmui` -Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return -`400` `invalid-body`. Changes apply immediately to new portrait generations. +Replaces the default template after validation. Invalid preset ids, age rules or numeric ranges +return `400` `invalid-body`. Already-created schools keep the copy they were created with. ### `GET /api/settings/swarmui/discovery` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 974f306..9f793d4 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -466,10 +466,6 @@ const en: Messages = { editPortraitPresets: 'Configure models', portraitPresetsHint: 'The model and prompts will be stored with this school.', portraitPresetsEdited: 'Set for this school.', - portraitPresets: 'Portrait presets', - editPortraitPresets: 'Configure models', - portraitPresetsHint: 'The model and prompts will be stored with this school.', - portraitPresetsEdited: 'Set for this school.', schoolSeed: 'Seed {seed}', schoolSeedLabel: 'Seed', schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index e022f1e..db2a74d 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -56,6 +56,7 @@ export interface CreateSchoolOptions { readonly countryId?: string; readonly nativeLanguage?: string; readonly seed?: number; + readonly portraitSettings?: SwarmUiSettingsFile; } export async function createSchool( @@ -74,6 +75,7 @@ export async function createSchool( countryId: extras.countryId ?? null, nativeLanguage: extras.nativeLanguage ?? null, seed: extras.seed ?? null, + portraitSettings: extras.portraitSettings ?? null, }), }); } diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts index 0b7307b..ae30d77 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fetchCatalog, fetchMods, + fetchSwarmUiSettings, type CatalogResponse, type MapLayout, type School, @@ -20,6 +21,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { ...actual, fetchCatalog: vi.fn(), fetchMods: vi.fn(), + fetchSwarmUiSettings: vi.fn(), }; }); @@ -108,6 +110,31 @@ describe('createSchoolDialog', () => { { id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] }, ]); vi.mocked(fetchCatalog).mockResolvedValue(catalog()); + vi.mocked(fetchSwarmUiSettings).mockReset(); + vi.mocked(fetchSwarmUiSettings).mockResolvedValue({ + activePresetId: 'default', + presets: [ + { + id: 'default', + label: 'Default', + model: 'template.safetensors', + steps: 4, + cfgScale: 1, + clipSkip: 1, + sampler: 'euler', + scheduler: 'normal', + seed: -1, + positive: 'base', + negative: 'neg', + positiveLoras: [], + negativeLoras: [], + avatar: { width: 512, height: 512, positive: '' }, + custom: { width: 512, height: 512, positive: '' }, + fullBody: { width: 512, height: 512, positive: '' }, + }, + ], + ageRules: [], + }); }); afterEach(() => { @@ -291,4 +318,22 @@ describe('map reset from the create editor', () => { await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint'))); void opened; }); + + it('shows a portrait-preset button that stores settings on the school', async () => { + const opened = createSchoolDialog({ + defaultStartDate: new Date('2024-09-01T00:00:00.000Z'), + suggestName: async () => 'North', + create: async () => school(), + }); + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled()); + const dialog = document.querySelector('dialog'); + if (dialog === null) { + throw new Error('create dialog is missing'); + } + + const presets = byText(dialog, 'button', t('editPortraitPresets')); + expect(presets.nextElementSibling?.textContent).toBe(t('portraitPresetsHint')); + await vi.waitFor(() => expect(fetchSwarmUiSettings).toHaveBeenCalled()); + void opened; + }); }); diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 2ea5fda..48ab2af 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -2,10 +2,12 @@ import { ApiError, fetchCatalog, fetchMods, + fetchSwarmUiSettings, type CatalogResponse, type CreateSchoolOptions as CreateExtras, type MapLayout, type School, + type SwarmUiSettingsFile, } from '../net/api.ts'; import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts'; import { getLocale } from '../i18n/locale.ts'; @@ -13,6 +15,7 @@ import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { mapEditorDialog } from './mapEditorDialog.ts'; import { Modal } from './modal.ts'; +import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts'; interface CreateSchoolOptions { /** Prefilled start of the school year, straight from the server config. */ @@ -34,6 +37,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + presetsHint.textContent = portraitEdited ? t('portraitPresetsEdited') : t('portraitPresetsHint'); + }; + const paintMapHint = (): void => { if (catalog === null || currentMap === null) { mapHint.textContent = ''; @@ -253,6 +274,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + if (busy) { + return; + } + + void swarmUiSettingsDialog(portraitSettings ?? undefined).then((next) => { + if (next === null) { + return; + } + + portraitSettings = next; + portraitEdited = true; + paintPresetsHint(); + presetsButton.focus(); + }); + }); + nativeRandomButton.addEventListener('click', () => { if (busy || catalog === null) { return; @@ -335,6 +376,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(school)) .catch((reason: unknown) => { @@ -345,6 +387,15 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + if (portraitSettings === null) { + portraitSettings = settings; + } + }) + .catch(() => { + /* Server copies swarmui.json at create if the form sends nothing. */ + }); return modal.open(nameInput); } @@ -405,6 +456,8 @@ function describe(reason: unknown): string { return t('errorUnknownCountry'); case 'unknown-native-language': return t('errorUnknownNativeLanguage'); + case 'invalid-portrait-settings': + return t('errorInvalidPortraitSettings'); default: return reason.message; } diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 1aefd8d..0140f84 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -11,7 +11,6 @@ import { schoolWord, t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { confirmDialog } from './confirmDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts'; -import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts'; import { SchoolCard } from './schoolCard.ts'; interface MainMenuOptions { @@ -37,10 +36,6 @@ export class MainMenu { class: 'button button--primary', type: 'button', }); - private readonly settingsButton = el('button', { - class: 'button', - type: 'button', - }); private readonly logoutButton = el('button', { class: 'button', type: 'button', @@ -57,7 +52,6 @@ export class MainMenu { constructor(private readonly options: MainMenuOptions) { this.createButton.addEventListener('click', () => void this.openCreateDialog()); - this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog()); this.logoutButton.addEventListener('click', () => this.options.onLogout()); this.status.hidden = true; @@ -66,7 +60,7 @@ export class MainMenu { 'header', { class: 'screen__header' }, this.title, - el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton), + el('div', { class: 'screen__actions' }, this.logoutButton, this.createButton), ), this.limitHint, this.status, @@ -85,7 +79,6 @@ export class MainMenu { localize(): void { this.title.textContent = t('schoolsTitle'); this.createButton.textContent = t('createSchool'); - this.settingsButton.textContent = t('settings'); this.logoutButton.textContent = t('sessionLogout'); this.emptyHint.textContent = t('emptySchools'); diff --git a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts index 75cc64b..1a0cba9 100644 --- a/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts +++ b/src/HSchool.Client/src/ui/swarmUiSettingsDialog.ts @@ -1,9 +1,7 @@ import { - ApiError, fetchGameStatus, fetchSwarmUiDiscovery, fetchSwarmUiSettings, - saveSwarmUiSettings, type SwarmUiDiscovery, type SwarmUiLoraEntry, type SwarmUiPresetDefinition, @@ -13,8 +11,8 @@ import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { Modal } from './modal.ts'; -export function swarmUiSettingsDialog(): Promise { - const modal = new Modal(false); +export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise { + const modal = new Modal(null); const error = el('p', { class: 'dialog__error' }); error.hidden = true; @@ -28,7 +26,7 @@ export function swarmUiSettingsDialog(): Promise { let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] }; let editingPresetId = ''; - const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) }); + const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) }); const saveButton = el('button', { class: 'button button--primary', type: 'submit' }); const addPresetButton = el('button', { class: 'button button--small', type: 'button' }); const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' }); @@ -116,7 +114,7 @@ export function swarmUiSettingsDialog(): Promise { localize(); try { const [settings, gameStatus, lists] = await Promise.all([ - fetchSwarmUiSettings(), + initial === undefined ? fetchSwarmUiSettings() : Promise.resolve(structuredClone(initial)), fetchGameStatus(), fetchSwarmUiDiscovery(), ]); @@ -153,14 +151,7 @@ export function swarmUiSettingsDialog(): Promise { syncActivePresetFromForm(); saveButton.disabled = true; error.hidden = true; - try { - await saveSwarmUiSettings(config); - modal.close(true); - } catch (err) { - showError(err instanceof ApiError ? err.message : t('settingsSaveFailed')); - } finally { - saveButton.disabled = false; - } + modal.close(structuredClone(config)); } function showError(message: string): void { diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index b269c1a..d817e97 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -48,6 +48,20 @@ internal static class SchoolEndpoints GameCommandQueue commands, CancellationToken cancellationToken) => { + SwarmUiConfigFile? portraitSettings = null; + if (request.PortraitSettings is not null) + { + try + { + request.PortraitSettings.Validate(); + portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings); + } + catch (InvalidOperationException ex) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-portrait-settings", ex.Message); + } + } + var command = new GameCommand.CreateSchool( request.Name ?? string.Empty, DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), @@ -56,6 +70,7 @@ internal static class SchoolEndpoints request.CountryId, request.NativeLanguage, request.Seed, + portraitSettings, NewCompletion()); commands.Enqueue(command); @@ -802,7 +817,8 @@ internal sealed record CreateSchoolRequest( MapLayout? Map, string? CountryId, string? NativeLanguage, - int? Seed); + int? Seed, + SwarmUiConfigFile? PortraitSettings); internal sealed record SchoolResponse( int Id, diff --git a/src/HSchool.Server/Api/SettingsEndpoints.cs b/src/HSchool.Server/Api/SettingsEndpoints.cs index 68b0a9e..d622084 100644 --- a/src/HSchool.Server/Api/SettingsEndpoints.cs +++ b/src/HSchool.Server/Api/SettingsEndpoints.cs @@ -8,6 +8,7 @@ internal static class SettingsEndpoints { var settings = endpoints.MapGroup("/api/settings"); + // Default template copied into a new school. Living schools generate from their own copy. settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current)) .WithName("GetSwarmUiSettings"); diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index e1e2f0c..5b41203 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -19,6 +19,7 @@ internal abstract record GameCommand string? CountryId, string? NativeLanguage, int? Seed, + SwarmUiConfigFile? PortraitSettings, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 3fd69cd..8ffe170 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -20,6 +20,7 @@ internal sealed class GameLoopService( GameMetrics metrics, SchoolStore store, ModContent mods, + SwarmUiSettingsStore swarmSettings, ILoggerFactory loggerFactory, ILogger logger) : BackgroundService { @@ -61,6 +62,20 @@ internal sealed class GameLoopService( } } + /// Create-time SwarmUI copy for this living school, or null on older saves. + public SwarmUiConfigFile? PortraitSettingsOf(int schoolId) + { + foreach (var worker in Volatile.Read(ref _publishedWorkers)) + { + if (worker.Id == schoolId) + { + return worker.PortraitSettings; + } + } + + return null; + } + /// /// Menu-style read of one school's published roster and frozen catalog. Does not post to the /// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks. @@ -414,7 +429,21 @@ internal sealed class GameLoopService( var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true); var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed); + var portrait = SwarmUiConfigFile.Clone(command.PortraitSettings ?? swarmSettings.Current); + var worker = SpawnWorker( + id, + normalized, + command.StartDate, + running: true, + ClockSpeed.DefaultIndex, + isNew: true, + packIds, + command.Map, + countryId, + climatePresetId, + nativeLanguage, + seed, + portraitSettings: portrait); Track(worker); worker.Start(); @@ -613,7 +642,8 @@ internal sealed class GameLoopService( save.NativeLanguage, createSeed: null, save.Presence, - save.DressRules); + save.DressRules, + save.PortraitSettings); worker.Start(); try @@ -669,7 +699,8 @@ internal sealed class GameLoopService( string? nativeLanguage, int? createSeed = null, IReadOnlyList? presence = null, - SchoolDressRules? dressRules = null) => + SchoolDressRules? dressRules = null, + SwarmUiConfigFile? portraitSettings = null) => new( id, name, @@ -685,6 +716,7 @@ internal sealed class GameLoopService( createSeed, presence, dressRules, + portraitSettings, _options, clients, metrics, diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index f4992a9..9bc4ad1 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -6,6 +6,7 @@ internal sealed class PortraitService( SchoolStore store, SwarmUiClient swarm, SwarmUiSettingsStore settingsStore, + GameLoopService loop, GameCommandQueue commands, ILogger logger) { @@ -74,7 +75,7 @@ internal sealed class PortraitService( return PortraitPromptBuildResult.UnknownSchool; } - var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind); var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved); return PortraitPromptBuildResult.Succeeded( kind, @@ -122,7 +123,7 @@ internal sealed class PortraitService( return PortraitGenerationResult.UnknownSchool; } - var profile = settingsStore.Resolve(outcome.Card.Age, kind); + var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind); var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra); try @@ -155,6 +156,9 @@ internal sealed class PortraitService( } } + private SwarmUiConfigFile ConfigFor(int schoolId) => + loop.PortraitSettingsOf(schoolId) ?? settingsStore.Current; + private async Task LookupPersonAsync( int schoolId, string personId, diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 6ca6c6f..36eff91 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -35,6 +35,9 @@ internal sealed class SchoolSave public IReadOnlyList? Presence { get; init; } public SchoolDressRules? DressRules { get; init; } + + /// Portrait presets copied at create. Generation reads this, not the global template. + public SwarmUiConfigFile? PortraitSettings { get; init; } } /// Allocates school ids that survive a process restart. @@ -172,6 +175,7 @@ internal sealed class SchoolStore NativeLanguage = save.NativeLanguage, Presence = save.Presence, DressRules = save.DressRules, + PortraitSettings = save.PortraitSettings, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 699e872..3d4cdff 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -38,6 +38,7 @@ internal sealed class SchoolWorker private readonly int? _createSeed; private readonly IReadOnlyList? _savedPresence; private readonly SchoolDressRules? _savedDressRules; + private readonly SwarmUiConfigFile? _portraitSettings; private readonly Action _onFailed; private readonly int _id; @@ -74,6 +75,7 @@ internal sealed class SchoolWorker int? createSeed, IReadOnlyList? savedPresence, SchoolDressRules? savedDressRules, + SwarmUiConfigFile? portraitSettings, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, @@ -96,6 +98,7 @@ internal sealed class SchoolWorker _createSeed = createSeed; _savedPresence = savedPresence; _savedDressRules = savedDressRules; + _portraitSettings = portraitSettings; _options = options; _clients = clients; _metrics = metrics; @@ -113,6 +116,9 @@ internal sealed class SchoolWorker /// Last clock the worker published. Menu requests read this; the live school stays here. public SchoolState Snapshot => Volatile.Read(ref _snapshot); + /// Presets copied at create. Null on older saves — generation then uses the global template. + public SwarmUiConfigFile? PortraitSettings => _portraitSettings; + /// Last roster composition. Published like ; needs live on entities. public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); @@ -1000,6 +1006,21 @@ internal sealed class SchoolWorker return generated; } + private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants) + { + foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person))) + { + foreach (var item in person.Items) + { + if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) + { + throw new SchoolContentUnavailableException( + $"School roster references unusable thing '{item.Def}'."); + } + } + } + } + private static string? ResolveCountryId(DefCatalog catalog, string? requested) { if (string.IsNullOrWhiteSpace(requested)) @@ -1055,6 +1076,7 @@ internal sealed class SchoolWorker NativeLanguage = _nativeLanguage, Presence = school.CapturePresence(), DressRules = school.DressRules, + PortraitSettings = _portraitSettings, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs index d825ddd..3184de9 100644 --- a/src/HSchool.Server/Game/SwarmUiSettingsStore.cs +++ b/src/HSchool.Server/Game/SwarmUiSettingsStore.cs @@ -233,6 +233,33 @@ internal sealed class SwarmUiConfigFile Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()], AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }], }; + + public static SwarmUiConfigFile Clone(SwarmUiConfigFile source) + { + var json = JsonSerializer.Serialize(source, CloneJson); + var copy = JsonSerializer.Deserialize(json, CloneJson) ?? CreateDefault(); + copy.Model = null; + copy.Steps = null; + copy.CfgScale = null; + copy.ClipSkip = null; + copy.Sampler = null; + copy.Scheduler = null; + copy.Seed = null; + copy.Positive = null; + copy.Negative = null; + copy.Avatar = null; + copy.Custom = null; + copy.FullBody = null; + copy.NormalizeAfterLoad(); + return copy; + } + + private static readonly JsonSerializerOptions CloneJson = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; } internal sealed class SwarmUiPresetDefinition diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index f36c34c..050543e 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; namespace HSchool.AppHost.Tests; @@ -104,6 +105,40 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); } + [Fact] + public async Task CreateSchool_CopiesPortraitSettingsIntoTheSave() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Пресеты школы", Start); + var directory = await SavesDirectoryAsync(client); + var json = await File.ReadAllTextAsync( + Path.Combine(directory, $"{school.Id}.json"), + TestContext.Current.CancellationToken); + using var document = JsonDocument.Parse(json); + Assert.True(document.RootElement.TryGetProperty("portraitSettings", out var presets)); + Assert.True(presets.TryGetProperty("presets", out var list)); + Assert.True(list.GetArrayLength() > 0); + } + + [Fact] + public async Task CreateSchool_RejectsEmptyPortraitPresets() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + using var response = await client.PostAsJsonAsync( + "/api/schools", + new + { + name = "Плохие пресеты", + startDate = Start, + portraitSettings = new { activePresetId = "missing", presets = Array.Empty(), ageRules = Array.Empty() }, + }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("invalid-portrait-settings", await SchoolApiTests.ProblemCodeAsync(response)); + } + [Fact] public async Task DeleteSchool_RemovesPortraitDirectory() { diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 8dc8a4c..5606d30 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -659,7 +659,15 @@ public class SchoolApiTests(AppHostFixture fixture) return payload.Path; } - private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage); + private sealed record SchoolSaveFile( + string? CountryId, + string? ClimatePresetId, + string? NativeLanguage, + SwarmUiSaveFile? PortraitSettings); + + private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList? Presets); + + private sealed record SwarmUiPresetSave(string? Id, string? Model); internal static readonly object SimpleCustomMap = new { diff --git a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs index 463ff28..1bc329d 100644 --- a/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiSettingsStoreTests.cs @@ -40,4 +40,18 @@ public class SwarmUiSettingsStoreTests Assert.Equal("legacy.safetensors", config.Presets[0].Model); Assert.Equal(12, config.Presets[0].Steps); } + + [Fact] + public void Clone_IsIndependentOfTheSource() + { + var source = SwarmUiConfigFile.CreateDefault(); + source.Presets[0].Model = "mutated.safetensors"; + + var copy = SwarmUiConfigFile.Clone(source); + copy.Presets[0].Model = "other.safetensors"; + + Assert.Equal("mutated.safetensors", source.Presets[0].Model); + Assert.Equal("other.safetensors", copy.Presets[0].Model); + Assert.Equal(source.ActivePresetId, copy.ActivePresetId); + } }