From b1b4f18b401d97319683cf5bb04c1d84867ff026 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:34:57 +0300 Subject: [PATCH 1/6] Add opinions storage, morning drift, and Connections card tab. --- docs/phases/41-opinions.md | 26 ++-- docs/protocol.md | 33 +++- src/HSchool.Client/src/i18n/strings.ts | 12 ++ src/HSchool.Client/src/net/api.ts | 23 +++ .../src/ui/managementPanel.test.ts | 1 + src/HSchool.Client/src/ui/personCard.test.ts | 30 ++++ src/HSchool.Client/src/ui/personCard.ts | 135 +++++++++++++++- src/HSchool.Content/PeopleDefs.cs | 40 +++++ src/HSchool.People/OpinionGenerator.cs | 97 ++++++++++++ src/HSchool.People/OpinionLabels.cs | 30 ++++ src/HSchool.People/OpinionStore.cs | 147 ++++++++++++++++++ src/HSchool.People/Roster.cs | 3 + src/HSchool.People/RosterGenerator.cs | 3 +- src/HSchool.Server/Api/PeopleModels.cs | 23 ++- src/HSchool.Server/Game/PersonCardReader.cs | 114 +++++++++++++- src/HSchool.Server/Game/SchoolWorker.cs | 6 + .../mods/core/defs/behavior/rules.jsonc | 16 ++ .../mods/core/localizations/en.jsonc | 7 + .../mods/core/localizations/ru.jsonc | 7 + src/HSchool.Simulation/ApparelWear.cs | 3 +- src/HSchool.Simulation/MorningOpinions.cs | 66 ++++++++ tests/HSchool.AppHost.Tests/PeopleApiTests.cs | 53 ++++++- .../OpinionGeneratorTests.cs | 55 +++++++ .../MorningOpinionsTests.cs | 117 ++++++++++++++ 24 files changed, 1020 insertions(+), 27 deletions(-) create mode 100644 src/HSchool.People/OpinionGenerator.cs create mode 100644 src/HSchool.People/OpinionLabels.cs create mode 100644 src/HSchool.People/OpinionStore.cs create mode 100644 src/HSchool.Simulation/MorningOpinions.cs create mode 100644 tests/HSchool.People.Tests/OpinionGeneratorTests.cs create mode 100644 tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs diff --git a/docs/phases/41-opinions.md b/docs/phases/41-opinions.md index 5984441..f785840 100644 --- a/docs/phases/41-opinions.md +++ b/docs/phases/41-opinions.md @@ -12,25 +12,25 @@ ## Задачи -- [ ] Мнение A→B: −100…100, несимметрично, ноль не хранить -- [ ] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам -- [ ] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных -- [ ] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; +- [x] Мнение A→B: −100…100, несимметрично, ноль не хранить +- [x] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам +- [x] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных +- [x] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; дошли до нуля — запись выкинуть -- [ ] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи +- [x] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи после первой загрузки-переписи -- [ ] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым -- [ ] Игрок с вкладки ничего не меняет -- [ ] `docs/protocol.md` — HTTP карточки +- [x] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым +- [x] Игрок с вкладки ничего не меняет +- [x] `docs/protocol.md` — HTTP карточки ## Тесты, без которых фаза не закрыта -- [ ] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи -- [ ] A→B и B→A могут различаться -- [ ] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) +- [x] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи +- [x] A→B и B→A могут различаться +- [x] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) ближе к нулю, семейное — к старту, не к нулю -- [ ] Карточка без отдельного запроса «всех пар школы»: только этот человек -- [ ] Клиентский тест: вкладка «Связи» не монтирует лог +- [x] Карточка без отдельного запроса «всех пар школы»: только этот человек +- [x] Клиентский тест: вкладка «Связи» не монтирует лог ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 9599e44..370eab4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -269,6 +269,10 @@ person. `customPortraitPrompt` is the last saved user prompt for the custom vari They are filled on the HTTP thread after the worker returns the card; generation does not happen on this request. +`connections` is the **Связи** tab: family links with `opinion` / `opinionLabel` from catalog bands, +plus `friends`, `enemies` and `others` (every non-family non-zero pair for this person only). +The client does not compute thresholds. There is no school-wide opinions endpoint. + ```json { "id": "f0.c0", @@ -321,7 +325,34 @@ not happen on this request. "hasAvatar": false, "hasCustom": false, "hasFullBody": false, - "customPortraitPrompt": null + "customPortraitPrompt": null, + "connections": { + "family": { + "parents": [ + { + "id": "f0.p1", + "fullName": "Иванова Ольга Михайловна", + "female": true, + "opinion": 75, + "opinionLabel": "близкие друзья" + } + ], + "children": [], + "siblings": [ + { + "id": "f0.c1", + "fullName": "Иванов Кирилл Петрович", + "female": false, + "opinion": 45, + "opinionLabel": "друзья" + } + ], + "partners": [] + }, + "friends": [], + "enemies": [], + "others": [] + } } ``` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 2018591..08ebe73 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -178,7 +178,13 @@ const ru = { peopleTabApparel: 'Одежда', peopleTabCarry: 'Ноша', peopleTabNow: 'Сейчас', + peopleTabConnections: 'Связи', peopleTabPortrait: 'Портрет', + peopleConnectionsFriends: 'Друзья', + peopleConnectionsEnemies: 'Враги', + peopleConnectionsSearch: 'Поиск по связям', + peopleConnectionsEmpty: 'Нет других связей.', + peopleOpinionValue: '{label} ({value})', peopleAtHome: 'дома', peopleApparelFit: 'Уместность: {value}', peopleLockerYes: 'Есть шкафчик', @@ -507,7 +513,13 @@ const en: Messages = { peopleTabApparel: 'Clothes', peopleTabCarry: 'Carried', peopleTabNow: 'Now', + peopleTabConnections: 'Connections', peopleTabPortrait: 'Portrait', + peopleConnectionsFriends: 'Friends', + peopleConnectionsEnemies: 'Enemies', + peopleConnectionsSearch: 'Search connections', + peopleConnectionsEmpty: 'No other connections.', + peopleOpinionValue: '{label} ({value})', peopleAtHome: 'home', peopleApparelFit: 'Fit: {value}', peopleLockerYes: 'Has a locker', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9601e68..f43e01c 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -262,6 +262,28 @@ export interface PersonRel { readonly id: string; readonly fullName: string; readonly female: boolean; + readonly opinion?: number | null; + readonly opinionLabel?: string | null; +} + +export interface PersonOpinionLink { + readonly id: string; + readonly fullName: string; + readonly female: boolean; + readonly opinion: number; + readonly opinionLabel: string; +} + +export interface PersonConnections { + readonly family: { + readonly parents: readonly PersonRel[]; + readonly children: readonly PersonRel[]; + readonly siblings: readonly PersonRel[]; + readonly partners: readonly PersonRel[]; + }; + readonly friends: readonly PersonOpinionLink[]; + readonly enemies: readonly PersonOpinionLink[]; + readonly others: readonly PersonOpinionLink[]; } export interface PersonCard { @@ -301,6 +323,7 @@ export interface PersonCard { readonly hasCustom: boolean; readonly hasFullBody: boolean; readonly customPortraitPrompt: string | null; + readonly connections: PersonConnections | null; } export interface WornItem { diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index c74dfe5..d067d83 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -78,6 +78,7 @@ function personCard(): PersonCard { hasCustom: false, hasFullBody: false, customPortraitPrompt: null, + connections: null, }; } diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 6746154..81517ad 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -64,6 +64,25 @@ function card(overrides: Partial = {}): PersonCard { hasCustom: false, hasFullBody: false, customPortraitPrompt: null, + connections: { + family: { + parents: [ + { + id: 'f0.p0', + fullName: 'Иванова Ольга', + female: true, + opinion: 75, + opinionLabel: 'близкие друзья', + }, + ], + children: [], + siblings: [], + partners: [], + }, + friends: [], + enemies: [], + others: [], + }, ...overrides, }; } @@ -261,6 +280,17 @@ describe('renderPersonCard', () => { expect(root.querySelector('.people__log-tools')).toBeNull(); }); + it('does not mount the log table on the connections tab', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card(), () => {}, { tab: 'connections', log: logPage() }); + + expect(root.querySelector('[data-card-tab="connections"]')?.hasAttribute('hidden')).toBe(false); + expect(root.textContent).toContain('близкие друзья'); + expect(root.querySelector('.people__log')).toBeNull(); + expect(root.querySelector('.people__log-tools')).toBeNull(); + }); + it('pages the log on the now tab', () => { setLocale('ru'); const onLogPage = vi.fn(); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 8fbbc72..0d62dae 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -4,13 +4,14 @@ import type { PersonLogDir, PersonLogPage, PersonLogQuery, + PersonOpinionLink, PersonRel, PersonRole, } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; -import { el } from './dom.ts'; +import { clear, el } from './dom.ts'; const ROLE_KEYS: Record = { student: 'peopleRoleStudent', @@ -18,7 +19,7 @@ const ROLE_KEYS: Record = { parent: 'peopleRoleParent', }; -export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait'; +export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait'; export interface RenderPersonCardOptions { readonly place?: string; @@ -110,6 +111,7 @@ export function renderPersonCard( apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }), carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }), now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }), + connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }), portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }), }; @@ -118,6 +120,7 @@ export function renderPersonCard( apparel: 'peopleTabApparel', carry: 'peopleTabCarry', now: 'peopleTabNow', + connections: 'peopleTabConnections', portrait: 'peopleTabPortrait', }; @@ -142,6 +145,7 @@ export function renderPersonCard( fillApparel(panels.apparel, card); fillCarry(panels.carry, card); fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); + fillConnections(panels.connections, card, onRelative); fillPortrait(panels.portrait, card, options); const showTab = (next: PersonCardTab): void => { @@ -160,7 +164,7 @@ export function renderPersonCard( showTab(tab); - parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.portrait); + parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait); } function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { @@ -253,7 +257,47 @@ function fillCarry(parent: HTMLElement, card: PersonCard): void { ); } - parent.append(grid); + parent.append(grid ); +} + +function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { + const connections = card.connections; + if (connections === null) { + return; + } + + const family = section(t('peopleFamily')); + appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative); + appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative); + appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative); + appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative); + if (family.childElementCount > 1) { + parent.append(family); + } + + appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative); + appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative); + + const search = el('input', { class: 'input people__input', type: 'search' }); + search.placeholder = t('peopleConnectionsSearch'); + const list = el('div', { class: 'people__connections-list' }); + const renderMatches = (): void => { + clear(list); + const query = search.value.trim().toLocaleLowerCase(); + const matches = connections.others.filter((row) => + query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query), + ); + if (matches.length === 0) { + list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') })); + return; + } + + appendOpinionLinks(list, '', matches, onRelative, false); + }; + + search.addEventListener('input', renderMatches); + parent.append(search, list); + renderMatches(); } function fillNow( @@ -682,3 +726,86 @@ function appendRelatives( ), ); } + +function appendRelativesWithOpinion( + parent: HTMLElement, + title: string, + relatives: readonly PersonRel[], + open: (id: string) => void, +): void { + if (relatives.length === 0) { + return; + } + + const list = el('span', { class: 'people__rel-list' }); + for (const relative of relatives) { + const caption = + relative.opinion !== null && + relative.opinion !== undefined && + relative.opinionLabel !== null && + relative.opinionLabel !== undefined + ? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion }) + : relative.fullName; + list.append( + el('button', { + class: 'people__link', + type: 'button', + text: caption, + title: relative.fullName, + onClick: () => open(relative.id), + }), + ); + } + + parent.append( + el( + 'div', + { class: 'people__rel' }, + el('span', { class: 'people__rel-title', text: title }), + list, + ), + ); +} + +function appendOpinionLinks( + parent: HTMLElement, + title: string, + rows: readonly PersonOpinionLink[], + open: (id: string) => void, + titled = true, +): void { + if (rows.length === 0) { + return; + } + + const block = el('div', { class: 'people__connections-block' }); + if (titled && title.length > 0) { + block.append(el('h4', { class: 'people__section-title', text: title })); + } + + const list = el('dl', { class: 'people__pairs' }); + for (const row of rows) { + list.append( + el( + 'div', + { class: 'people__pair' }, + el( + 'dt', + {}, + el('button', { + class: 'people__link', + type: 'button', + text: row.fullName, + onClick: () => open(row.id), + }), + ), + el('dd', { + text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }), + }), + ), + ); + } + + block.append(list); + parent.append(block); +} diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 460cef4..cc85c87 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -308,6 +308,38 @@ public sealed class BehaviorDef : Def /// Game minutes for ChangeClothes* actions. public float ChangeClothesMinutes { get; init; } = 5f; + /// Parent → child start. Child → parent uses . + public int OpinionParentToChildStart { get; init; } = 85; + + public int OpinionChildToParentStart { get; init; } = 75; + + public int OpinionSiblingStart { get; init; } = 45; + + public int OpinionPartnerStart { get; init; } = 65; + + /// Points each work morning moves an opinion toward family basis or zero. + public int OpinionDriftPerMorning { get; init; } = 3; + + /// How many non-family friends or enemies the card lists at the top. + public int OpinionTopCount { get; init; } = 5; + + /// + /// Caption bands for −100…100. Highest the value still meets wins. + /// Empty falls back to . + /// + public IReadOnlyList OpinionBands { get; init; } = DefaultOpinionBands; + + public static IReadOnlyList DefaultOpinionBands { get; } = + [ + new() { Min = 70, Id = "OpinionCloseFriend" }, + new() { Min = 40, Id = "OpinionFriend" }, + new() { Min = 20, Id = "OpinionPleasant" }, + new() { Min = 1, Id = "OpinionAcquaintance" }, + new() { Min = -1, Id = "OpinionStrained" }, + new() { Min = -40, Id = "OpinionDislike" }, + new() { Min = -100, Id = "OpinionEnemy" }, + ]; + public static IReadOnlyList DefaultConditionBands { get; } = [ new() { Min = 0.75f, Id = "ApparelConditionIntact" }, @@ -325,6 +357,14 @@ public sealed class ApparelConditionBand public required string Id { get; init; } } +/// One caption on an opinion value. is a locale key, not a Def. +public sealed class OpinionBand +{ + public int Min { get; init; } + + public required string Id { get; init; } +} + public enum BodyAttributeKind { Number, diff --git a/src/HSchool.People/OpinionGenerator.cs b/src/HSchool.People/OpinionGenerator.cs new file mode 100644 index 0000000..f356d21 --- /dev/null +++ b/src/HSchool.People/OpinionGenerator.cs @@ -0,0 +1,97 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Seeds family opinions at generation or when an old save lacks them. +public static class OpinionGenerator +{ + public static Roster SeedFamily(DefCatalog catalog, Roster roster) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(roster); + + var rules = catalog.BehaviorRules; + if (rules is null) + { + return roster; + } + + var people = new Person[roster.People.Count]; + var changed = false; + for (var i = 0; i < roster.People.Count; i++) + { + var person = roster.People[i]; + var updated = SeedPersonFamilyOpinions(roster, rules, roster.People, person); + people[i] = updated; + changed |= !ReferenceEquals(updated, person); + } + + return changed ? roster with { People = people } : roster; + } + + public static bool NeedsFamilyOpinions(Roster roster) + { + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + foreach (var family in roster.Families) + { + foreach (var childId in family.ChildIds) + { + if (!people.TryGetValue(childId, out var child)) + { + continue; + } + + foreach (var parentId in family.ParentIds) + { + if (!child.Opinions.ContainsKey(parentId)) + { + return true; + } + } + } + } + + return false; + } + + public static Person EnsureMutableOpinions(Person person) + { + if (person.Opinions is Dictionary) + { + return person; + } + + return person with + { + Opinions = new Dictionary(person.Opinions, StringComparer.Ordinal), + }; + } + + private static Person SeedPersonFamilyOpinions( + Roster roster, + BehaviorDef rules, + IReadOnlyList rosterPeople, + Person person) + { + var mutable = EnsureMutableOpinions(person); + var changed = !ReferenceEquals(mutable, person); + var peopleById = rosterPeople.ToDictionary(row => row.Id, StringComparer.Ordinal); + foreach (var targetId in OpinionStore.FamilyMemberIds(roster, person)) + { + if (!peopleById.TryGetValue(targetId, out var target)) + { + continue; + } + + var basis = OpinionStore.FamilyBasis(roster, rules, mutable, target); + if (basis == 0 || mutable.Opinions.ContainsKey(targetId)) + { + continue; + } + + changed |= OpinionStore.Set(mutable, targetId, basis); + } + + return changed ? mutable : person; + } +} diff --git a/src/HSchool.People/OpinionLabels.cs b/src/HSchool.People/OpinionLabels.cs new file mode 100644 index 0000000..b59bae6 --- /dev/null +++ b/src/HSchool.People/OpinionLabels.cs @@ -0,0 +1,30 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Threshold captions live in the catalog. The client draws what it is told. +public static class OpinionLabels +{ + public const int MinValue = -100; + public const int MaxValue = 100; + + public static string BandId(BehaviorDef? rules, int value) + { + var bands = rules?.OpinionBands is { Count: > 0 } listed + ? listed + : BehaviorDef.DefaultOpinionBands; + OpinionBand? best = null; + foreach (var band in bands) + { + if (value >= band.Min && (best is null || band.Min > best.Min)) + { + best = band; + } + } + + return best?.Id ?? "OpinionAcquaintance"; + } + + public static string Label(DefCatalog catalog, string locale, int value) => + catalog.Text(locale, BandId(catalog.BehaviorRules, value)); +} diff --git a/src/HSchool.People/OpinionStore.cs b/src/HSchool.People/OpinionStore.cs new file mode 100644 index 0000000..eb0dcc6 --- /dev/null +++ b/src/HSchool.People/OpinionStore.cs @@ -0,0 +1,147 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Sparse A→B opinions on a person. Zero is not stored. +public static class OpinionStore +{ + public static int? Get(Person person, string targetId) => + person.Opinions.TryGetValue(targetId, out var value) ? value : null; + + public static bool Set(Person person, string targetId, int value) + { + value = Math.Clamp(value, OpinionLabels.MinValue, OpinionLabels.MaxValue); + if (value == 0) + { + return Remove(person, targetId); + } + + if (person.Opinions is not IDictionary dict) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.TryGetValue(targetId, out var existing) && existing == value) + { + return false; + } + + dict[targetId] = value; + return true; + } + + public static bool Remove(Person person, string targetId) + { + if (person.Opinions is not IDictionary dict) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + return dict.Remove(targetId); + } + + public static int FamilyBasis(Roster roster, BehaviorDef rules, Person from, Person to) + { + var family = roster.Families.FirstOrDefault(candidate => + candidate.Id.Equals(from.FamilyId, StringComparison.Ordinal) + && candidate.Id.Equals(to.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return 0; + } + + var fromParent = InFamily(family.ParentIds, from.Id); + var toParent = InFamily(family.ParentIds, to.Id); + var fromChild = InFamily(family.ChildIds, from.Id); + var toChild = InFamily(family.ChildIds, to.Id); + + if (fromParent && toChild) + { + return rules.OpinionParentToChildStart; + } + + if (fromChild && toParent) + { + return rules.OpinionChildToParentStart; + } + + if (fromChild && toChild && !from.Id.Equals(to.Id, StringComparison.Ordinal)) + { + return rules.OpinionSiblingStart; + } + + if (fromParent && toParent && !from.Id.Equals(to.Id, StringComparison.Ordinal)) + { + return rules.OpinionPartnerStart; + } + + return 0; + } + + public static int MoveToward(int current, int target, int step) + { + if (current == target) + { + return current; + } + + if (current < target) + { + return Math.Min(current + step, target); + } + + return Math.Max(current - step, target); + } + + public static HashSet FamilyMemberIds(Roster roster, Person person) + { + var family = roster.Families.FirstOrDefault(candidate => + candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return []; + } + + var ids = new HashSet(StringComparer.Ordinal); + foreach (var id in family.ParentIds) + { + if (!id.Equals(person.Id, StringComparison.Ordinal)) + { + ids.Add(id); + } + } + + foreach (var id in family.ChildIds) + { + if (!id.Equals(person.Id, StringComparison.Ordinal)) + { + ids.Add(id); + } + } + + return ids; + } + + private static bool InFamily(IReadOnlyList ids, string id) + { + foreach (var candidate in ids) + { + if (candidate.Equals(id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 8a917b3..9b58c68 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -52,6 +52,9 @@ public sealed record Person /// Changing-room node when this pupil won a locker slot. Null for staff and the rest. public string? LockerRoomId { get; init; } + /// What this person thinks of others, A→B. Sparse; zero is not stored. + public IReadOnlyDictionary Opinions { get; init; } = new Dictionary(StringComparer.Ordinal); + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 4699b81..f9aaebb 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -44,7 +44,8 @@ public static class RosterGenerator } var classes = FillClasses(demand.Classes, people); - return LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); + var roster = LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); + return OpinionGenerator.SeedFamily(catalog, roster); } /// diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index afaa4e8..d951e12 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -81,7 +81,8 @@ internal sealed record PersonCardResponse( bool HasAvatar = false, bool HasCustom = false, bool HasFullBody = false, - string? CustomPortraitPrompt = null); + string? CustomPortraitPrompt = null, + PersonConnectionsResponse? Connections = null); internal sealed record WornItemResponse( string DefName, @@ -111,7 +112,25 @@ internal sealed record PersonFamilyResponse( IReadOnlyList Siblings, IReadOnlyList Partners); -internal sealed record PersonRelResponse(string Id, string FullName, bool Female); +internal sealed record PersonRelResponse( + string Id, + string FullName, + bool Female, + int? Opinion = null, + string? OpinionLabel = null); + +internal sealed record PersonOpinionLinkResponse( + string Id, + string FullName, + bool Female, + int Opinion, + string OpinionLabel); + +internal sealed record PersonConnectionsResponse( + PersonFamilyResponse Family, + IReadOnlyList Friends, + IReadOnlyList Enemies, + IReadOnlyList Others); internal sealed record DirectoryResponse(IReadOnlyList People); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index fb66f50..671959d 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -88,7 +88,8 @@ internal static class PersonCardReader Capacity(person, skills, catalog), person.LockerRoomId is not null || person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), - person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal))); + person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)), + Connections: Connections(roster, person, catalog, locale)); } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) @@ -373,8 +374,8 @@ internal static class PersonCardReader } var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); - var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal); - var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal); + var inParents = InFamily(family.ParentIds, person.Id); + var inChildren = InFamily(family.ChildIds, person.Id); return new PersonFamilyResponse( inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [], inParents ? Relatives(family.ChildIds, people, except: person.Id) : [], @@ -382,6 +383,19 @@ internal static class PersonCardReader inParents ? Relatives(family.ParentIds, people, except: person.Id) : []); } + private static bool InFamily(IReadOnlyList ids, string id) + { + foreach (var candidate in ids) + { + if (candidate.Equals(id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + private static IReadOnlyList Relatives( IReadOnlyList ids, IReadOnlyDictionary people, @@ -400,4 +414,98 @@ internal static class PersonCardReader return rows; } + + private static PersonConnectionsResponse Connections( + Roster roster, + Person person, + DefCatalog? catalog, + string locale) + { + var familyIds = OpinionStore.FamilyMemberIds(roster, person); + var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); + var family = FamilyWithOpinions(roster, person, people, catalog, locale); + var others = new List(); + foreach (var (targetId, value) in person.Opinions) + { + if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target)) + { + continue; + } + + others.Add(Link(target, value, catalog, locale)); + } + + others.Sort((left, right) => + { + var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion)); + return byAbs != 0 + ? byAbs + : string.Compare(left.FullName, right.FullName, StringComparison.Ordinal); + }); + + var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5; + var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray(); + var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray(); + return new PersonConnectionsResponse(family, friends, enemies, others); + } + + private static PersonFamilyResponse FamilyWithOpinions( + Roster roster, + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return new PersonFamilyResponse([], [], [], []); + } + + var inParents = InFamily(family.ParentIds, person.Id); + var inChildren = InFamily(family.ChildIds, person.Id); + return new PersonFamilyResponse( + inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [], + inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], + inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], + inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []); + } + + private static IReadOnlyList RelativesWithOpinions( + IReadOnlyList ids, + IReadOnlyDictionary people, + Person person, + string except, + DefCatalog? catalog, + string locale) + { + var rows = new List(); + foreach (var id in ids) + { + if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) + { + continue; + } + + int? opinion = null; + string? label = null; + if (person.Opinions.TryGetValue(id, out var value)) + { + opinion = value; + label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value); + } + + rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label)); + } + + return rows; + } + + private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) => + new( + target.Id, + target.Name.Full, + target.Female, + opinion, + catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion)); } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index e066858..7a3b0ee 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -984,6 +984,12 @@ internal sealed class SchoolWorker generated = true; } + if (OpinionGenerator.NeedsFamilyOpinions(roster)) + { + roster = OpinionGenerator.SeedFamily(catalog, roster); + generated = true; + } + roster = LockerAssigner.Apply(catalog, map, roster); if (!RosterFit.Matches(roster, demand)) diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index bd665eb..079b9f3 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -46,4 +46,20 @@ "heavyOuterAboveC": 15, "apparelGoalWeight": 6, "changeClothesMinutes": 5, + // Opinion starts and morning drift (slice 9 phase 41). + "opinionParentToChildStart": 85, + "opinionChildToParentStart": 75, + "opinionSiblingStart": 45, + "opinionPartnerStart": 65, + "opinionDriftPerMorning": 3, + "opinionTopCount": 5, + "opinionBands": [ + { "min": 70, "id": "OpinionCloseFriend" }, + { "min": 40, "id": "OpinionFriend" }, + { "min": 20, "id": "OpinionPleasant" }, + { "min": 1, "id": "OpinionAcquaintance" }, + { "min": -1, "id": "OpinionStrained" }, + { "min": -40, "id": "OpinionDislike" }, + { "min": -100, "id": "OpinionEnemy" }, + ], } diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index f88bf3f..2fc6e69 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -173,6 +173,13 @@ "ApparelConditionWorn": "worn", "ApparelConditionTorn": "torn", "ApparelConditionRags": "in rags", + "OpinionCloseFriend": "close friends", + "OpinionFriend": "friends", + "OpinionPleasant": "pleasant", + "OpinionAcquaintance": "acquaintances", + "OpinionStrained": "strained", + "OpinionDislike": "dislike", + "OpinionEnemy": "enemies", "ActionStarted": "started: {0}", "ActionEnded": "finished: {0}", "ApparelReplaced": "got a new {0}", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index f7bdfaf..1514078 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -173,6 +173,13 @@ "ApparelConditionWorn": "поношенная", "ApparelConditionTorn": "порванная", "ApparelConditionRags": "висит лохмотьями", + "OpinionCloseFriend": "близкие друзья", + "OpinionFriend": "друзья", + "OpinionPleasant": "приятели", + "OpinionAcquaintance": "знакомые", + "OpinionStrained": "натянуто", + "OpinionDislike": "неприязнь", + "OpinionEnemy": "враги", "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}", "ApparelReplaced": "получил новую {0}", diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs index cf21fd5..b14d53b 100644 --- a/src/HSchool.Simulation/ApparelWear.cs +++ b/src/HSchool.Simulation/ApparelWear.cs @@ -53,9 +53,10 @@ internal static class ApparelWear } var morningChanged = MorningDress.Apply(school); + var opinionsChanged = MorningOpinions.Apply(school); var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays) && ReplaceRags(school); - return bandCrossed | morningChanged | ragsReplaced; + return bandCrossed | morningChanged | opinionsChanged | ragsReplaced; } internal static bool CrossedDayStart(DateTime before, DateTime after) diff --git a/src/HSchool.Simulation/MorningOpinions.cs b/src/HSchool.Simulation/MorningOpinions.cs new file mode 100644 index 0000000..d216043 --- /dev/null +++ b/src/HSchool.Simulation/MorningOpinions.cs @@ -0,0 +1,66 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// Drifts non-zero opinions toward family basis or zero at six on work mornings. +internal static class MorningOpinions +{ + public static bool Apply(School school) + { + if (school.Catalog?.BehaviorRules is not { } rules || school.Roster is null) + { + return false; + } + + if (!SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)) + { + return false; + } + + var peopleById = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var changed = false; + foreach (var person in school.Roster.People) + { + changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning); + } + + return changed; + } + + private static bool DriftPerson( + Roster roster, + BehaviorDef rules, + IReadOnlyDictionary peopleById, + Person person, + int drift) + { + if (person.Opinions.Count == 0) + { + return false; + } + + var changed = false; + foreach (var (targetId, current) in person.Opinions.ToArray()) + { + if (!peopleById.TryGetValue(targetId, out var target)) + { + changed |= OpinionStore.Remove(person, targetId); + continue; + } + + var basis = OpinionStore.FamilyBasis(roster, rules, person, target); + var next = OpinionStore.MoveToward(current, basis, drift); + if (next == 0) + { + changed |= OpinionStore.Remove(person, targetId); + } + else + { + changed |= OpinionStore.Set(person, targetId, next); + } + } + + return changed; + } +} diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 511f474..b43cdcb 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -177,6 +177,36 @@ public class PeopleApiTests(AppHostFixture fixture) Assert.Contains(motherCard.Family.Children, child => child.Id == card.Id); } + [Fact] + public async Task Card_ConnectionsCarryOnlyThisPersonsOpinions() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Связи карточка", Start, seed: 1); + + var pupils = await GetPeopleAsync(client, school.Id, "role=student&pageSize=20"); + var pupil = pupils.People.First(person => person.Roles.Contains("student")); + var card = await GetCardAsync(client, school.Id, pupil.Id); + + Assert.NotNull(card.Connections); + Assert.NotEmpty(card.Connections!.Family.Parents); + Assert.All(card.Connections.Family.Parents, parent => + { + Assert.NotNull(parent.Opinion); + Assert.True(parent.Opinion > 0); + Assert.False(string.IsNullOrWhiteSpace(parent.OpinionLabel)); + }); + + var linkedIds = card.Connections.Others.Select(row => row.Id).ToHashSet(StringComparer.Ordinal); + foreach (var other in card.Connections.Friends.Concat(card.Connections.Enemies)) + { + Assert.DoesNotContain(other.Id, linkedIds); + } + + using var bulk = await client.GetAsync($"/api/schools/{school.Id}/people/opinions", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, bulk.StatusCode); + } + /// /// Phases 7 and 9 together: the roster survives a restart, and what comes back is the /// composition *after* the first-September intake. Generating from the seed again would @@ -320,7 +350,8 @@ public class PeopleApiTests(AppHostFixture fixture) IReadOnlyList Skills, IReadOnlyList Traits, IReadOnlyList Needs, - PersonFamilyResponse Family); + PersonFamilyResponse Family, + PersonConnectionsResponse? Connections); private sealed record LabeledStatResponse(string Id, string Label, string Value); @@ -332,7 +363,25 @@ public class PeopleApiTests(AppHostFixture fixture) IReadOnlyList Siblings, IReadOnlyList Partners); - private sealed record PersonRelResponse(string Id, string FullName, bool Female); + private sealed record PersonRelResponse( + string Id, + string FullName, + bool Female, + int? Opinion = null, + string? OpinionLabel = null); + + private sealed record PersonOpinionLinkResponse( + string Id, + string FullName, + bool Female, + int Opinion, + string OpinionLabel); + + private sealed record PersonConnectionsResponse( + PersonFamilyResponse Family, + IReadOnlyList Friends, + IReadOnlyList Enemies, + IReadOnlyList Others); private sealed record DirectoryResponse(IReadOnlyList People); diff --git a/tests/HSchool.People.Tests/OpinionGeneratorTests.cs b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs new file mode 100644 index 0000000..0250069 --- /dev/null +++ b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs @@ -0,0 +1,55 @@ +namespace HSchool.People.Tests; + +public class OpinionGeneratorTests +{ + [Fact] + public void ParentAndChild_HavePositiveOpinionsBothWays_AfterGeneration() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + + var child = roster.People.First(person => person.IsStudent && !person.IsParent); + var family = roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + Assert.NotEmpty(family.ParentIds); + + foreach (var parentId in family.ParentIds) + { + Assert.True(child.Opinions.TryGetValue(parentId, out var toParent)); + Assert.True(toParent > 0); + + var parent = people[parentId]; + Assert.True(parent.Opinions.TryGetValue(child.Id, out var toChild)); + Assert.True(toChild > 0); + } + } + + [Fact] + public void Classmates_HaveNoOpinionEntry_AfterGeneration() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupils = roster.People.Where(person => person.IsStudent).ToArray(); + Assert.True(pupils.Length >= 2); + + var first = pupils[0]; + var classmate = pupils.First(person => + person.Id != first.Id + && !OpinionStore.FamilyMemberIds(roster, first).Contains(person.Id)); + + Assert.False(first.Opinions.ContainsKey(classmate.Id)); + Assert.False(classmate.Opinions.ContainsKey(first.Id)); + } + + [Fact] + public void ParentToChild_AndChildToParent_CanDiffer() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var child = roster.People.First(person => person.IsStudent && !person.IsParent); + var parentId = roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)).ParentIds[0]; + var parent = people[parentId]; + + Assert.True(parent.Opinions.TryGetValue(child.Id, out var parentView)); + Assert.True(child.Opinions.TryGetValue(parentId, out var childView)); + Assert.NotEqual(parentView, childView); + } +} diff --git a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs new file mode 100644 index 0000000..e306204 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs @@ -0,0 +1,117 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class MorningOpinionsTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime TuesdayEvening = new(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc); + private static readonly DateTime WednesdayMorning = new(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void SeveralWorkMornings_DriftClassmateTowardZero_FamilyTowardStart() + { + using var school = OpenStaffed(); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + var family = school.Roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + var parentId = family.ParentIds[0]; + var rules = school.Catalog!.BehaviorRules!; + + OpinionStore.Set(child, classmate.Id, 30); + OpinionStore.Set(child, parentId, rules.OpinionChildToParentStart - 15); + + AdvanceTo(school, TuesdayEvening); + for (var day = 0; day < 3; day++) + { + while (school.Clock.Time < WednesdayMorning.AddDays(day)) + { + school.Tick(0.2d, 5d); + } + } + + Assert.True(child.Opinions.TryGetValue(classmate.Id, out var classmateView)); + Assert.True(classmateView < 30); + Assert.True(classmateView <= 30 - rules.OpinionDriftPerMorning); + + Assert.True(child.Opinions.TryGetValue(parentId, out var parentView)); + Assert.True(parentView > rules.OpinionChildToParentStart - 15); + Assert.True(parentView <= rules.OpinionChildToParentStart); + } + + [Fact] + public void SkipEmpty_AppliesOpinionDrift() + { + using var school = OpenEmpty(TuesdayEvening); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + + OpinionStore.Set(child, classmate.Id, 12); + var before = child.Opinions[classmate.Id]; + + Assert.True(school.TrySkipEmpty().Succeeded); + + Assert.True(child.Opinions.TryGetValue(classmate.Id, out var after)); + Assert.True(after < before); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static School OpenStaffed() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var school = School.Create(1, "Мнения", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + return school; + } + + private static School OpenEmpty(DateTime start) + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start); + var school = School.Create(1, "Мнения ночь", start, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + return school; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +} From 3fdbbf488e51e6d1f8bd95553a6696acb6f02213 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:35:36 +0300 Subject: [PATCH 2/6] phase 41: mark complete --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index ee5f927..922905a 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -194,7 +194,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [41. Мнения и «Связи»](41-opinions.md) | ⬜ | −100…100, семья-старт, вкладка карточки | +| [41. Мнения и «Связи»](41-opinions.md) | ✅ | −100…100, семья-старт, вкладка карточки | **Этап B — разговоры.** Кружок, язык, шёпот. From e35aa0d89321aaf1d78c341a61edcd0337f3ef6a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:40:55 +0300 Subject: [PATCH 3/6] phase 39: mark in progress --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 4ea4406..8e134ec 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -173,7 +173,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [39. Хозяин школы](39-school-owners.md) | ⬜ | `owner`, 2 слота на игрока, мои / чужие, гость без управления | +| [39. Хозяин школы](39-school-owners.md) | 🔄 | `owner`, 2 слота на игрока, мои / чужие, гость без управления | 39 стоит на 38. From db95de0ddf45421fac491b704c4309b50be556e8 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 08:01:46 +0300 Subject: [PATCH 4/6] Update AGENTS.md with new task documentation and enhance Russian and English localization for portrait settings. --- .claude/skills/side-work/SKILL.md | 70 ++++++++++++++++++++++++++ AGENTS.md | 1 + src/HSchool.Client/src/i18n/strings.ts | 14 ++++++ 3 files changed, 85 insertions(+) create mode 100644 .claude/skills/side-work/SKILL.md diff --git a/.claude/skills/side-work/SKILL.md b/.claude/skills/side-work/SKILL.md new file mode 100644 index 0000000..76a1026 --- /dev/null +++ b/.claude/skills/side-work/SKILL.md @@ -0,0 +1,70 @@ +--- +name: side-work +description: >- + Обсуждает задачу вне запланированных срезов, пишет под неё фазы и делает на + отдельной ветке. Используй при «сделай отдельно», «вне среза», «отдельная + задача», «не из фаз», «side-work», «обсуди и сделай». Не для «сделай фазу N» + (/phase-work) и не для ревью (/phase-review). +--- + +# Отдельная задача вне среза + +Задачи нет в индексе — и в уже стоящие срезы она не дописывается. Сначала +согласовать форму, потом документы в **своём** срезе, потом код как у +`/phase-work`. Код до документов не писать. + +«Обсуди и сделай» — идти до конца после короткого согласования. Стоп, только +если выбор меняет протокол, сейв или инвариант. + +## 1. Обсуждение + +Прочитай `docs/phases/README.md`, инварианты в `AGENTS.md` и затронутые +дизайн-доки. Это уже чья-то фаза в индексе — скажи и отдай `/phase-work`. + +Два–пять вопросов, два–три варианта, **рекомендация обязательна**. Спросить +только форму: что увидит игрок; протокол / HTTP / сейв; какой инвариант; одна +фаза или несколько, и можно ли остановиться посередине целой школой. + +«Ок», «делай», «как рекомендуешь» — согласие. Молчание — нет. Соседнее «заодно» +в отчёт, не в объём. + +## 2. Куда класть + +**Мелкое** (баг, дырка, одна кнопка) — фаза в секции **«Вне очереди»** в конце +индекса. Это срез без номера, чтобы не спорить со срезом 10. Дизайн — `##` в +`docs/design/off-queue.md`; файла нет — заведи с заголовком, зачем он. + +**Крупное** (этапы, остановка посередине, новый кусок игры) — именованный срез +со следующим номером и `docs/design/.md`. «Вне очереди» номер не занимает. + +Номер фазы: максимум по `docs/phases/*.md` и индексу, плюс один, затем +`git branch --list "phase/-*"`. Занято — следующий. Гонку ловят ветки, не +индекс: у новой фазы на `main` строки ещё нет. + +## 3. Документы + +Как у соседней фазы, не новый жанр. Каркас тот же: Зависимости, Зачем, Задачи, +Тесты без которых фаза не закрыта, Критерий готовности, Стоп. + +«Тесты…» — контракт для `/phase-review`: каждый пункт станет отдельным тестом +по Testing policy в `AGENTS.md`. Дизайн: зачем; «Было / Стало / Почему», если +меняется принятое; что не входит. Индекс — новая строка, статус ⬜. Новый срез, +куда ходят люди — строка в Orientation `AGENTS.md`. + +Протокол, HTTP, сейв — назвать явно. Версию не бампать: это коммит с кодом. + +Покажи срез, фазы и Стоп. Просил только документы — здесь конец. Просил сделать +— сразу шаг 4. + +## 4. Работа + +Прочитай [phase-work](../phase-work/SKILL.md) и следуй ему. + +Отличия: фазу ты завёл сам; заявка `git branch phase/-` **до** кода; +новые файлы и 🔄 живут на этой ветке. Один цикл — одна фаза. «Сделай всё» — +по порядку, каждая своим циклом `/phase-work`. Три фазы в одну ветку не паковать. + +## Отчёт + +Форма и куда легли документы; фаза и ветка; тесты списком; что прогнал; что +осталось ⬜; слита ли ветка в `main`. diff --git a/AGENTS.md b/AGENTS.md index df500bf..f9adb9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ way; this file is *how to work in them*. | login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 38–39 | | clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 | | talks, opinions, fights, romance pack | [`docs/design/social.md`](docs/design/social.md) — slice 9, phases 41–47 | +| a task that is not in any slice | `.claude/skills/side-work` — discuss, document in «Вне очереди» or a new named slice, then implement | ## Commands diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index ddf05da..974f306 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -96,6 +96,7 @@ const ru = { errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorUnknownCountry: 'Выбранная страна не найдена.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.', + errorInvalidPortraitSettings: 'Пресеты портретов этой школы заданы неверно.', catalogLoadFailed: 'Не удалось загрузить каталог модов.', modsTitle: 'Моды', @@ -108,6 +109,10 @@ const ru = { editMap: 'Редактировать карту', mapDefaultHint: 'Будет использована карта по умолчанию.', mapEditedHint: 'Карта изменена.', + portraitPresets: 'Пресеты портретов', + editPortraitPresets: 'Настроить модели', + portraitPresetsHint: 'Модель и промпты сохранятся в этой школе.', + portraitPresetsEdited: 'Заданы для этой школы.', schoolSeed: 'Сид {seed}', schoolSeedLabel: 'Сид', schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.', @@ -444,6 +449,7 @@ const en: Messages = { errorInvalidCatalog: 'The selected packs could not be loaded.', errorUnknownCountry: 'The selected country is not in the catalog.', errorUnknownNativeLanguage: 'The selected native language is not in that country.', + errorInvalidPortraitSettings: 'This school\'s portrait presets are not valid.', catalogLoadFailed: 'Could not load the mod catalog.', modsTitle: 'Mods', @@ -456,6 +462,14 @@ const en: Messages = { editMap: 'Edit map', mapDefaultHint: 'The default map will be used.', mapEditedHint: 'The map has been edited.', + portraitPresets: 'Portrait presets', + 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.', From 9d96108516a79df924dfb4a6cabb4e1c3e585cae Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 08:17:58 +0300 Subject: [PATCH 5/6] 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. --- docs/design/session.md | 1 + docs/phases/39-school-owners.md | 3 +- docs/protocol.md | 19 ++++--- src/HSchool.Client/src/i18n/strings.ts | 4 -- src/HSchool.Client/src/net/api.ts | 2 + .../src/ui/createSchoolDialog.test.ts | 45 ++++++++++++++++ .../src/ui/createSchoolDialog.ts | 53 +++++++++++++++++++ src/HSchool.Client/src/ui/mainMenu.ts | 9 +--- .../src/ui/swarmUiSettingsDialog.ts | 19 ++----- src/HSchool.Server/Api/SchoolEndpoints.cs | 18 ++++++- src/HSchool.Server/Api/SettingsEndpoints.cs | 1 + src/HSchool.Server/Game/GameCommand.cs | 1 + src/HSchool.Server/Game/GameLoopService.cs | 38 +++++++++++-- src/HSchool.Server/Game/PortraitService.cs | 8 ++- src/HSchool.Server/Game/SchoolStore.cs | 4 ++ src/HSchool.Server/Game/SchoolWorker.cs | 22 ++++++++ .../Game/SwarmUiSettingsStore.cs | 27 ++++++++++ .../HSchool.AppHost.Tests/PortraitApiTests.cs | 35 ++++++++++++ tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 10 +++- .../SwarmUiSettingsStoreTests.cs | 14 +++++ 20 files changed, 293 insertions(+), 40 deletions(-) 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); + } } From 7266678ee93b411eba19610412a5f0de6f5f253e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 08:21:21 +0300 Subject: [PATCH 6/6] Mark phase 42 talk-circles as in progress. --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index ea891ae..14f4b69 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -200,7 +200,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, учительская, телефон, вместе | +| [42. Темы и кружки](42-talk-circles.md) | 🔄 | `TopicDef`, 2–4 человека, учительская, телефон, вместе | | [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | 42 стоит на 41; 43 — на 42.