Add portrait settings support to school creation and update related API and UI components. Enhance error handling for invalid presets and ensure proper cloning of settings. Update tests to validate new functionality.
This commit is contained in:
@@ -128,6 +128,7 @@ Welcome не трогаем, версию не бампим. Глобальны
|
|||||||
|
|
||||||
- пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение);
|
- пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение);
|
||||||
- удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`;
|
- удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`;
|
||||||
|
- генерировать портреты — **можно**: пресеты лежат на школе, модель одна и та же;
|
||||||
- видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да.
|
- видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да.
|
||||||
|
|
||||||
Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько
|
Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
`others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine`
|
`others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine`
|
||||||
- [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый)
|
- [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый)
|
||||||
- [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный.
|
- [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный.
|
||||||
`OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят
|
`OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят.
|
||||||
|
**Исключение:** `POST .../portrait` — гость генерирует теми же пресетами школы; это не управление
|
||||||
- [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет
|
- [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет
|
||||||
- [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск).
|
- [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск).
|
||||||
Карта и люди остаются
|
Карта и люди остаются
|
||||||
|
|||||||
+13
-6
@@ -164,7 +164,8 @@ Body:
|
|||||||
"map": null,
|
"map": null,
|
||||||
"countryId": "Russia",
|
"countryId": "Russia",
|
||||||
"nativeLanguage": null,
|
"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`.
|
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
|
`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.
|
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 |
|
| 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` `invalid-catalog` | The selected packs could not be loaded. |
|
||||||
| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. |
|
| `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` `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. |
|
| `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.
|
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`
|
### `GET /api/settings/swarmui`
|
||||||
|
|
||||||
Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler,
|
Returns the **default** SwarmUI preset template (`swarmui.json`): named presets (model, steps,
|
||||||
LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets.
|
sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies
|
||||||
Portrait generation resolves the preset from the person's age before building the prompt.
|
this into its save as `portraitSettings`. Living schools generate from that copy, not from this
|
||||||
|
file.
|
||||||
|
|
||||||
### `PUT /api/settings/swarmui`
|
### `PUT /api/settings/swarmui`
|
||||||
|
|
||||||
Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return
|
Replaces the default template after validation. Invalid preset ids, age rules or numeric ranges
|
||||||
`400` `invalid-body`. Changes apply immediately to new portrait generations.
|
return `400` `invalid-body`. Already-created schools keep the copy they were created with.
|
||||||
|
|
||||||
### `GET /api/settings/swarmui/discovery`
|
### `GET /api/settings/swarmui/discovery`
|
||||||
|
|
||||||
|
|||||||
@@ -466,10 +466,6 @@ const en: Messages = {
|
|||||||
editPortraitPresets: 'Configure models',
|
editPortraitPresets: 'Configure models',
|
||||||
portraitPresetsHint: 'The model and prompts will be stored with this school.',
|
portraitPresetsHint: 'The model and prompts will be stored with this school.',
|
||||||
portraitPresetsEdited: 'Set for 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}',
|
schoolSeed: 'Seed {seed}',
|
||||||
schoolSeedLabel: 'Seed',
|
schoolSeedLabel: 'Seed',
|
||||||
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
|
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface CreateSchoolOptions {
|
|||||||
readonly countryId?: string;
|
readonly countryId?: string;
|
||||||
readonly nativeLanguage?: string;
|
readonly nativeLanguage?: string;
|
||||||
readonly seed?: number;
|
readonly seed?: number;
|
||||||
|
readonly portraitSettings?: SwarmUiSettingsFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchool(
|
export async function createSchool(
|
||||||
@@ -74,6 +75,7 @@ export async function createSchool(
|
|||||||
countryId: extras.countryId ?? null,
|
countryId: extras.countryId ?? null,
|
||||||
nativeLanguage: extras.nativeLanguage ?? null,
|
nativeLanguage: extras.nativeLanguage ?? null,
|
||||||
seed: extras.seed ?? null,
|
seed: extras.seed ?? null,
|
||||||
|
portraitSettings: extras.portraitSettings ?? null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
fetchMods,
|
fetchMods,
|
||||||
|
fetchSwarmUiSettings,
|
||||||
type CatalogResponse,
|
type CatalogResponse,
|
||||||
type MapLayout,
|
type MapLayout,
|
||||||
type School,
|
type School,
|
||||||
@@ -20,6 +21,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
fetchCatalog: vi.fn(),
|
fetchCatalog: vi.fn(),
|
||||||
fetchMods: 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'] },
|
{ id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] },
|
||||||
]);
|
]);
|
||||||
vi.mocked(fetchCatalog).mockResolvedValue(catalog());
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -291,4 +318,22 @@ describe('map reset from the create editor', () => {
|
|||||||
await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint')));
|
await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint')));
|
||||||
void opened;
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
fetchMods,
|
fetchMods,
|
||||||
|
fetchSwarmUiSettings,
|
||||||
type CatalogResponse,
|
type CatalogResponse,
|
||||||
type CreateSchoolOptions as CreateExtras,
|
type CreateSchoolOptions as CreateExtras,
|
||||||
type MapLayout,
|
type MapLayout,
|
||||||
type School,
|
type School,
|
||||||
|
type SwarmUiSettingsFile,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
|
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
|
||||||
import { getLocale } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
@@ -13,6 +15,7 @@ import { t } from '../i18n/strings.ts';
|
|||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
import { mapEditorDialog } from './mapEditorDialog.ts';
|
import { mapEditorDialog } from './mapEditorDialog.ts';
|
||||||
import { Modal } from './modal.ts';
|
import { Modal } from './modal.ts';
|
||||||
|
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
||||||
|
|
||||||
interface CreateSchoolOptions {
|
interface CreateSchoolOptions {
|
||||||
/** Prefilled start of the school year, straight from the server config. */
|
/** Prefilled start of the school year, straight from the server config. */
|
||||||
@@ -34,6 +37,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
let currentMap: MapLayout | null = null;
|
let currentMap: MapLayout | null = null;
|
||||||
let countryId: string | null = null;
|
let countryId: string | null = null;
|
||||||
let nativeLanguageId: string | null = null;
|
let nativeLanguageId: string | null = null;
|
||||||
|
let portraitSettings: SwarmUiSettingsFile | null = null;
|
||||||
|
let portraitEdited = false;
|
||||||
|
|
||||||
const modsField = el('div', { class: 'field' });
|
const modsField = el('div', { class: 'field' });
|
||||||
const modsLabel = el('span', { class: 'field__label' });
|
const modsLabel = el('span', { class: 'field__label' });
|
||||||
@@ -80,6 +85,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
el('div', { class: 'field__row' }, editMapButton, mapHint),
|
el('div', { class: 'field__row' }, editMapButton, mapHint),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const presetsLabel = el('span', { class: 'field__label' });
|
||||||
|
const presetsButton = el('button', { class: 'button', type: 'button' });
|
||||||
|
const presetsHint = el('p', { class: 'hint' });
|
||||||
|
const presetsField = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'field' },
|
||||||
|
presetsLabel,
|
||||||
|
el('div', { class: 'field__row' }, presetsButton, presetsHint),
|
||||||
|
);
|
||||||
|
|
||||||
const nameInput = el('input', { class: 'input', type: 'text' });
|
const nameInput = el('input', { class: 'input', type: 'text' });
|
||||||
nameInput.maxLength = 40;
|
nameInput.maxLength = 40;
|
||||||
nameInput.required = true;
|
nameInput.required = true;
|
||||||
@@ -113,6 +128,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
countryField,
|
countryField,
|
||||||
nativeField,
|
nativeField,
|
||||||
mapField,
|
mapField,
|
||||||
|
presetsField,
|
||||||
seedField,
|
seedField,
|
||||||
error,
|
error,
|
||||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||||
@@ -127,6 +143,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
randomButton.toggleAttribute('disabled', value);
|
randomButton.toggleAttribute('disabled', value);
|
||||||
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
|
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
|
||||||
editMapButton.toggleAttribute('disabled', waiting);
|
editMapButton.toggleAttribute('disabled', waiting);
|
||||||
|
presetsButton.toggleAttribute('disabled', waiting);
|
||||||
seedInput.toggleAttribute('disabled', value);
|
seedInput.toggleAttribute('disabled', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +152,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paintPresetsHint = (): void => {
|
||||||
|
presetsHint.textContent = portraitEdited ? t('portraitPresetsEdited') : t('portraitPresetsHint');
|
||||||
|
};
|
||||||
|
|
||||||
const paintMapHint = (): void => {
|
const paintMapHint = (): void => {
|
||||||
if (catalog === null || currentMap === null) {
|
if (catalog === null || currentMap === null) {
|
||||||
mapHint.textContent = '';
|
mapHint.textContent = '';
|
||||||
@@ -253,6 +274,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
startLabel.textContent = t('gameStart');
|
startLabel.textContent = t('gameStart');
|
||||||
mapLabel.textContent = t('mapEditorTitle');
|
mapLabel.textContent = t('mapEditorTitle');
|
||||||
editMapButton.textContent = t('editMap');
|
editMapButton.textContent = t('editMap');
|
||||||
|
presetsLabel.textContent = t('portraitPresets');
|
||||||
|
presetsButton.textContent = t('editPortraitPresets');
|
||||||
|
paintPresetsHint();
|
||||||
nameInput.placeholder = t('schoolNamePlaceholder');
|
nameInput.placeholder = t('schoolNamePlaceholder');
|
||||||
randomButton.textContent = t('randomName');
|
randomButton.textContent = t('randomName');
|
||||||
randomButton.title = t('randomNameTitle');
|
randomButton.title = t('randomNameTitle');
|
||||||
@@ -279,6 +303,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
presetsButton.addEventListener('click', () => {
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void swarmUiSettingsDialog(portraitSettings ?? undefined).then((next) => {
|
||||||
|
if (next === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
portraitSettings = next;
|
||||||
|
portraitEdited = true;
|
||||||
|
paintPresetsHint();
|
||||||
|
presetsButton.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
nativeRandomButton.addEventListener('click', () => {
|
nativeRandomButton.addEventListener('click', () => {
|
||||||
if (busy || catalog === null) {
|
if (busy || catalog === null) {
|
||||||
return;
|
return;
|
||||||
@@ -335,6 +376,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
countryId: countryId ?? undefined,
|
countryId: countryId ?? undefined,
|
||||||
nativeLanguage: nativeLanguageId ?? undefined,
|
nativeLanguage: nativeLanguageId ?? undefined,
|
||||||
seed: seed ?? undefined,
|
seed: seed ?? undefined,
|
||||||
|
portraitSettings: portraitSettings ?? undefined,
|
||||||
})
|
})
|
||||||
.then((school) => modal.close(school))
|
.then((school) => modal.close(school))
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
@@ -345,6 +387,15 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
|
|
||||||
modal.element.append(title, form);
|
modal.element.append(title, form);
|
||||||
void paintMods();
|
void paintMods();
|
||||||
|
void fetchSwarmUiSettings()
|
||||||
|
.then((settings) => {
|
||||||
|
if (portraitSettings === null) {
|
||||||
|
portraitSettings = settings;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* Server copies swarmui.json at create if the form sends nothing. */
|
||||||
|
});
|
||||||
|
|
||||||
return modal.open(nameInput);
|
return modal.open(nameInput);
|
||||||
}
|
}
|
||||||
@@ -405,6 +456,8 @@ function describe(reason: unknown): string {
|
|||||||
return t('errorUnknownCountry');
|
return t('errorUnknownCountry');
|
||||||
case 'unknown-native-language':
|
case 'unknown-native-language':
|
||||||
return t('errorUnknownNativeLanguage');
|
return t('errorUnknownNativeLanguage');
|
||||||
|
case 'invalid-portrait-settings':
|
||||||
|
return t('errorInvalidPortraitSettings');
|
||||||
default:
|
default:
|
||||||
return reason.message;
|
return reason.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { schoolWord, t } from '../i18n/strings.ts';
|
|||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
import { confirmDialog } from './confirmDialog.ts';
|
import { confirmDialog } from './confirmDialog.ts';
|
||||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||||
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
|
||||||
import { SchoolCard } from './schoolCard.ts';
|
import { SchoolCard } from './schoolCard.ts';
|
||||||
|
|
||||||
interface MainMenuOptions {
|
interface MainMenuOptions {
|
||||||
@@ -37,10 +36,6 @@ export class MainMenu {
|
|||||||
class: 'button button--primary',
|
class: 'button button--primary',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
});
|
});
|
||||||
private readonly settingsButton = el('button', {
|
|
||||||
class: 'button',
|
|
||||||
type: 'button',
|
|
||||||
});
|
|
||||||
private readonly logoutButton = el('button', {
|
private readonly logoutButton = el('button', {
|
||||||
class: 'button',
|
class: 'button',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -57,7 +52,6 @@ export class MainMenu {
|
|||||||
|
|
||||||
constructor(private readonly options: MainMenuOptions) {
|
constructor(private readonly options: MainMenuOptions) {
|
||||||
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
||||||
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
|
|
||||||
this.logoutButton.addEventListener('click', () => this.options.onLogout());
|
this.logoutButton.addEventListener('click', () => this.options.onLogout());
|
||||||
this.status.hidden = true;
|
this.status.hidden = true;
|
||||||
|
|
||||||
@@ -66,7 +60,7 @@ export class MainMenu {
|
|||||||
'header',
|
'header',
|
||||||
{ class: 'screen__header' },
|
{ class: 'screen__header' },
|
||||||
this.title,
|
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.limitHint,
|
||||||
this.status,
|
this.status,
|
||||||
@@ -85,7 +79,6 @@ export class MainMenu {
|
|||||||
localize(): void {
|
localize(): void {
|
||||||
this.title.textContent = t('schoolsTitle');
|
this.title.textContent = t('schoolsTitle');
|
||||||
this.createButton.textContent = t('createSchool');
|
this.createButton.textContent = t('createSchool');
|
||||||
this.settingsButton.textContent = t('settings');
|
|
||||||
this.logoutButton.textContent = t('sessionLogout');
|
this.logoutButton.textContent = t('sessionLogout');
|
||||||
this.emptyHint.textContent = t('emptySchools');
|
this.emptyHint.textContent = t('emptySchools');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
|
||||||
fetchGameStatus,
|
fetchGameStatus,
|
||||||
fetchSwarmUiDiscovery,
|
fetchSwarmUiDiscovery,
|
||||||
fetchSwarmUiSettings,
|
fetchSwarmUiSettings,
|
||||||
saveSwarmUiSettings,
|
|
||||||
type SwarmUiDiscovery,
|
type SwarmUiDiscovery,
|
||||||
type SwarmUiLoraEntry,
|
type SwarmUiLoraEntry,
|
||||||
type SwarmUiPresetDefinition,
|
type SwarmUiPresetDefinition,
|
||||||
@@ -13,8 +11,8 @@ import { t } from '../i18n/strings.ts';
|
|||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
import { Modal } from './modal.ts';
|
import { Modal } from './modal.ts';
|
||||||
|
|
||||||
export function swarmUiSettingsDialog(): Promise<boolean> {
|
export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile | null> {
|
||||||
const modal = new Modal<boolean>(false);
|
const modal = new Modal<SwarmUiSettingsFile | null>(null);
|
||||||
const error = el('p', { class: 'dialog__error' });
|
const error = el('p', { class: 'dialog__error' });
|
||||||
error.hidden = true;
|
error.hidden = true;
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
|
|||||||
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
|
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
|
||||||
let editingPresetId = '';
|
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 saveButton = el('button', { class: 'button button--primary', type: 'submit' });
|
||||||
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
|
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
|
||||||
const addAgeRuleButton = 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<boolean> {
|
|||||||
localize();
|
localize();
|
||||||
try {
|
try {
|
||||||
const [settings, gameStatus, lists] = await Promise.all([
|
const [settings, gameStatus, lists] = await Promise.all([
|
||||||
fetchSwarmUiSettings(),
|
initial === undefined ? fetchSwarmUiSettings() : Promise.resolve(structuredClone(initial)),
|
||||||
fetchGameStatus(),
|
fetchGameStatus(),
|
||||||
fetchSwarmUiDiscovery(),
|
fetchSwarmUiDiscovery(),
|
||||||
]);
|
]);
|
||||||
@@ -153,14 +151,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
|
|||||||
syncActivePresetFromForm();
|
syncActivePresetFromForm();
|
||||||
saveButton.disabled = true;
|
saveButton.disabled = true;
|
||||||
error.hidden = true;
|
error.hidden = true;
|
||||||
try {
|
modal.close(structuredClone(config));
|
||||||
await saveSwarmUiSettings(config);
|
|
||||||
modal.close(true);
|
|
||||||
} catch (err) {
|
|
||||||
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
|
|
||||||
} finally {
|
|
||||||
saveButton.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(message: string): void {
|
function showError(message: string): void {
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ internal static class SchoolEndpoints
|
|||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
CancellationToken cancellationToken) =>
|
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(
|
var command = new GameCommand.CreateSchool(
|
||||||
request.Name ?? string.Empty,
|
request.Name ?? string.Empty,
|
||||||
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
|
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
|
||||||
@@ -56,6 +70,7 @@ internal static class SchoolEndpoints
|
|||||||
request.CountryId,
|
request.CountryId,
|
||||||
request.NativeLanguage,
|
request.NativeLanguage,
|
||||||
request.Seed,
|
request.Seed,
|
||||||
|
portraitSettings,
|
||||||
NewCompletion<SchoolCreationOutcome>());
|
NewCompletion<SchoolCreationOutcome>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
|
|
||||||
@@ -802,7 +817,8 @@ internal sealed record CreateSchoolRequest(
|
|||||||
MapLayout? Map,
|
MapLayout? Map,
|
||||||
string? CountryId,
|
string? CountryId,
|
||||||
string? NativeLanguage,
|
string? NativeLanguage,
|
||||||
int? Seed);
|
int? Seed,
|
||||||
|
SwarmUiConfigFile? PortraitSettings);
|
||||||
|
|
||||||
internal sealed record SchoolResponse(
|
internal sealed record SchoolResponse(
|
||||||
int Id,
|
int Id,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ internal static class SettingsEndpoints
|
|||||||
{
|
{
|
||||||
var settings = endpoints.MapGroup("/api/settings");
|
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))
|
settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current))
|
||||||
.WithName("GetSwarmUiSettings");
|
.WithName("GetSwarmUiSettings");
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal abstract record GameCommand
|
|||||||
string? CountryId,
|
string? CountryId,
|
||||||
string? NativeLanguage,
|
string? NativeLanguage,
|
||||||
int? Seed,
|
int? Seed,
|
||||||
|
SwarmUiConfigFile? PortraitSettings,
|
||||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||||
|
|
||||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal sealed class GameLoopService(
|
|||||||
GameMetrics metrics,
|
GameMetrics metrics,
|
||||||
SchoolStore store,
|
SchoolStore store,
|
||||||
ModContent mods,
|
ModContent mods,
|
||||||
|
SwarmUiSettingsStore swarmSettings,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILogger<GameLoopService> logger) : BackgroundService
|
ILogger<GameLoopService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,20 @@ internal sealed class GameLoopService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Create-time SwarmUI copy for this living school, or null on older saves.</summary>
|
||||||
|
public SwarmUiConfigFile? PortraitSettingsOf(int schoolId)
|
||||||
|
{
|
||||||
|
foreach (var worker in Volatile.Read(ref _publishedWorkers))
|
||||||
|
{
|
||||||
|
if (worker.Id == schoolId)
|
||||||
|
{
|
||||||
|
return worker.PortraitSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Menu-style read of one school's published roster and frozen catalog. Does not post to the
|
/// 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.
|
/// 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 nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true);
|
||||||
var climatePresetId = CountryClimate.Pick(country, seed, 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);
|
Track(worker);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -613,7 +642,8 @@ internal sealed class GameLoopService(
|
|||||||
save.NativeLanguage,
|
save.NativeLanguage,
|
||||||
createSeed: null,
|
createSeed: null,
|
||||||
save.Presence,
|
save.Presence,
|
||||||
save.DressRules);
|
save.DressRules,
|
||||||
|
save.PortraitSettings);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -669,7 +699,8 @@ internal sealed class GameLoopService(
|
|||||||
string? nativeLanguage,
|
string? nativeLanguage,
|
||||||
int? createSeed = null,
|
int? createSeed = null,
|
||||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||||
SchoolDressRules? dressRules = null) =>
|
SchoolDressRules? dressRules = null,
|
||||||
|
SwarmUiConfigFile? portraitSettings = null) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -685,6 +716,7 @@ internal sealed class GameLoopService(
|
|||||||
createSeed,
|
createSeed,
|
||||||
presence,
|
presence,
|
||||||
dressRules,
|
dressRules,
|
||||||
|
portraitSettings,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ internal sealed class PortraitService(
|
|||||||
SchoolStore store,
|
SchoolStore store,
|
||||||
SwarmUiClient swarm,
|
SwarmUiClient swarm,
|
||||||
SwarmUiSettingsStore settingsStore,
|
SwarmUiSettingsStore settingsStore,
|
||||||
|
GameLoopService loop,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
ILogger<PortraitService> logger)
|
ILogger<PortraitService> logger)
|
||||||
{
|
{
|
||||||
@@ -74,7 +75,7 @@ internal sealed class PortraitService(
|
|||||||
return PortraitPromptBuildResult.UnknownSchool;
|
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);
|
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
|
||||||
return PortraitPromptBuildResult.Succeeded(
|
return PortraitPromptBuildResult.Succeeded(
|
||||||
kind,
|
kind,
|
||||||
@@ -122,7 +123,7 @@ internal sealed class PortraitService(
|
|||||||
return PortraitGenerationResult.UnknownSchool;
|
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);
|
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -155,6 +156,9 @@ internal sealed class PortraitService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SwarmUiConfigFile ConfigFor(int schoolId) =>
|
||||||
|
loop.PortraitSettingsOf(schoolId) ?? settingsStore.Current;
|
||||||
|
|
||||||
private async Task<PersonCardResult> LookupPersonAsync(
|
private async Task<PersonCardResult> LookupPersonAsync(
|
||||||
int schoolId,
|
int schoolId,
|
||||||
string personId,
|
string personId,
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ internal sealed class SchoolSave
|
|||||||
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
||||||
|
|
||||||
public SchoolDressRules? DressRules { get; init; }
|
public SchoolDressRules? DressRules { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
|
||||||
|
public SwarmUiConfigFile? PortraitSettings { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||||
@@ -172,6 +175,7 @@ internal sealed class SchoolStore
|
|||||||
NativeLanguage = save.NativeLanguage,
|
NativeLanguage = save.NativeLanguage,
|
||||||
Presence = save.Presence,
|
Presence = save.Presence,
|
||||||
DressRules = save.DressRules,
|
DressRules = save.DressRules,
|
||||||
|
PortraitSettings = save.PortraitSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly int? _createSeed;
|
private readonly int? _createSeed;
|
||||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly SchoolDressRules? _savedDressRules;
|
private readonly SchoolDressRules? _savedDressRules;
|
||||||
|
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
|
|
||||||
private readonly int _id;
|
private readonly int _id;
|
||||||
@@ -74,6 +75,7 @@ internal sealed class SchoolWorker
|
|||||||
int? createSeed,
|
int? createSeed,
|
||||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SchoolDressRules? savedDressRules,
|
SchoolDressRules? savedDressRules,
|
||||||
|
SwarmUiConfigFile? portraitSettings,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
GameMetrics metrics,
|
GameMetrics metrics,
|
||||||
@@ -96,6 +98,7 @@ internal sealed class SchoolWorker
|
|||||||
_createSeed = createSeed;
|
_createSeed = createSeed;
|
||||||
_savedPresence = savedPresence;
|
_savedPresence = savedPresence;
|
||||||
_savedDressRules = savedDressRules;
|
_savedDressRules = savedDressRules;
|
||||||
|
_portraitSettings = portraitSettings;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
@@ -113,6 +116,9 @@ internal sealed class SchoolWorker
|
|||||||
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
||||||
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
||||||
|
|
||||||
|
/// <summary>Presets copied at create. Null on older saves — generation then uses the global template.</summary>
|
||||||
|
public SwarmUiConfigFile? PortraitSettings => _portraitSettings;
|
||||||
|
|
||||||
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||||
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||||
|
|
||||||
@@ -1000,6 +1006,21 @@ internal sealed class SchoolWorker
|
|||||||
return generated;
|
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)
|
private static string? ResolveCountryId(DefCatalog catalog, string? requested)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(requested))
|
if (string.IsNullOrWhiteSpace(requested))
|
||||||
@@ -1055,6 +1076,7 @@ internal sealed class SchoolWorker
|
|||||||
NativeLanguage = _nativeLanguage,
|
NativeLanguage = _nativeLanguage,
|
||||||
Presence = school.CapturePresence(),
|
Presence = school.CapturePresence(),
|
||||||
DressRules = school.DressRules,
|
DressRules = school.DressRules,
|
||||||
|
PortraitSettings = _portraitSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -233,6 +233,33 @@ internal sealed class SwarmUiConfigFile
|
|||||||
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
|
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
|
||||||
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
|
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<SwarmUiConfigFile>(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
|
internal sealed class SwarmUiPresetDefinition
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace HSchool.AppHost.Tests;
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
@@ -104,6 +105,40 @@ public class PortraitApiTests(AppHostFixture fixture)
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
|
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<object>(), ageRules = Array.Empty<object>() },
|
||||||
|
},
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal("invalid-portrait-settings", await SchoolApiTests.ProblemCodeAsync(response));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteSchool_RemovesPortraitDirectory()
|
public async Task DeleteSchool_RemovesPortraitDirectory()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -659,7 +659,15 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
return payload.Path;
|
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<SwarmUiPresetSave>? Presets);
|
||||||
|
|
||||||
|
private sealed record SwarmUiPresetSave(string? Id, string? Model);
|
||||||
|
|
||||||
internal static readonly object SimpleCustomMap = new
|
internal static readonly object SimpleCustomMap = new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,4 +40,18 @@ public class SwarmUiSettingsStoreTests
|
|||||||
Assert.Equal("legacy.safetensors", config.Presets[0].Model);
|
Assert.Equal("legacy.safetensors", config.Presets[0].Model);
|
||||||
Assert.Equal(12, config.Presets[0].Steps);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user