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:
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<School
|
||||
let currentMap: MapLayout | null = null;
|
||||
let countryId: string | null = null;
|
||||
let nativeLanguageId: string | null = null;
|
||||
let portraitSettings: SwarmUiSettingsFile | null = null;
|
||||
let portraitEdited = false;
|
||||
|
||||
const modsField = el('div', { class: 'field' });
|
||||
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),
|
||||
);
|
||||
|
||||
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' });
|
||||
nameInput.maxLength = 40;
|
||||
nameInput.required = true;
|
||||
@@ -113,6 +128,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
countryField,
|
||||
nativeField,
|
||||
mapField,
|
||||
presetsField,
|
||||
seedField,
|
||||
error,
|
||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||
@@ -127,6 +143,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
randomButton.toggleAttribute('disabled', value);
|
||||
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
|
||||
editMapButton.toggleAttribute('disabled', waiting);
|
||||
presetsButton.toggleAttribute('disabled', waiting);
|
||||
seedInput.toggleAttribute('disabled', value);
|
||||
};
|
||||
|
||||
@@ -135,6 +152,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
error.hidden = false;
|
||||
};
|
||||
|
||||
const paintPresetsHint = (): void => {
|
||||
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<School
|
||||
startLabel.textContent = t('gameStart');
|
||||
mapLabel.textContent = t('mapEditorTitle');
|
||||
editMapButton.textContent = t('editMap');
|
||||
presetsLabel.textContent = t('portraitPresets');
|
||||
presetsButton.textContent = t('editPortraitPresets');
|
||||
paintPresetsHint();
|
||||
nameInput.placeholder = t('schoolNamePlaceholder');
|
||||
randomButton.textContent = t('randomName');
|
||||
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', () => {
|
||||
if (busy || catalog === null) {
|
||||
return;
|
||||
@@ -335,6 +376,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
countryId: countryId ?? undefined,
|
||||
nativeLanguage: nativeLanguageId ?? undefined,
|
||||
seed: seed ?? undefined,
|
||||
portraitSettings: portraitSettings ?? undefined,
|
||||
})
|
||||
.then((school) => modal.close(school))
|
||||
.catch((reason: unknown) => {
|
||||
@@ -345,6 +387,15 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
|
||||
modal.element.append(title, form);
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
const modal = new Modal<boolean>(false);
|
||||
export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile | null> {
|
||||
const modal = new Modal<SwarmUiSettingsFile | null>(null);
|
||||
const error = el('p', { class: 'dialog__error' });
|
||||
error.hidden = true;
|
||||
|
||||
@@ -28,7 +26,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -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<SchoolCreationOutcome>());
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ internal abstract record GameCommand
|
||||
string? CountryId,
|
||||
string? NativeLanguage,
|
||||
int? Seed,
|
||||
SwarmUiConfigFile? PortraitSettings,
|
||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||
|
||||
@@ -20,6 +20,7 @@ internal sealed class GameLoopService(
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
SwarmUiSettingsStore swarmSettings,
|
||||
ILoggerFactory loggerFactory,
|
||||
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>
|
||||
/// 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<PresenceSnapshot>? 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,
|
||||
|
||||
@@ -6,6 +6,7 @@ internal sealed class PortraitService(
|
||||
SchoolStore store,
|
||||
SwarmUiClient swarm,
|
||||
SwarmUiSettingsStore settingsStore,
|
||||
GameLoopService loop,
|
||||
GameCommandQueue commands,
|
||||
ILogger<PortraitService> 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<PersonCardResult> LookupPersonAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
|
||||
@@ -35,6 +35,9 @@ internal sealed class SchoolSave
|
||||
public IReadOnlyList<PresenceSnapshot>? Presence { 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>
|
||||
@@ -172,6 +175,7 @@ internal sealed class SchoolStore
|
||||
NativeLanguage = save.NativeLanguage,
|
||||
Presence = save.Presence,
|
||||
DressRules = save.DressRules,
|
||||
PortraitSettings = save.PortraitSettings,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed class SchoolWorker
|
||||
private readonly int? _createSeed;
|
||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||
private readonly SchoolDressRules? _savedDressRules;
|
||||
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
private readonly int _id;
|
||||
@@ -74,6 +75,7 @@ internal sealed class SchoolWorker
|
||||
int? createSeed,
|
||||
IReadOnlyList<PresenceSnapshot>? 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
|
||||
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
||||
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>
|
||||
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)
|
||||
|
||||
@@ -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<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
|
||||
|
||||
Reference in New Issue
Block a user