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/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/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/phases/README.md b/docs/phases/README.md index 4ea4406..14f4b69 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. @@ -194,13 +194,13 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [41. Мнения и «Связи»](41-opinions.md) | 🔄 | −100…100, семья-старт, вкладка карточки | +| [41. Мнения и «Связи»](41-opinions.md) | ✅ | −100…100, семья-старт, вкладка карточки | **Этап B — разговоры.** Кружок, язык, шёпот. | Фаза | Статус | Зачем | | --- | --- | --- | -| [42. Темы и кружки](42-talk-circles.md) | ⬜ | `TopicDef`, 2–4 человека, учительская, телефон, вместе | +| [42. Темы и кружки](42-talk-circles.md) | 🔄 | `TopicDef`, 2–4 человека, учительская, телефон, вместе | | [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | 42 стоит на 41; 43 — на 42. diff --git a/docs/protocol.md b/docs/protocol.md index da1de52..5ec0a2b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -174,7 +174,8 @@ Body: "map": null, "countryId": "Russia", "nativeLanguage": null, - "seed": null + "seed": null, + "portraitSettings": null } ``` @@ -188,6 +189,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 | | --- | --- | @@ -201,6 +206,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` | This player already owns `maxSchools` schools. | | `409` `server-full` | The process already runs `maxSchoolsTotal` schools. | @@ -320,6 +326,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", @@ -372,7 +382,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": [] + } } ``` @@ -451,14 +488,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 c884b5e..5c63cc9 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -100,6 +100,7 @@ const ru = { errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorUnknownCountry: 'Выбранная страна не найдена.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.', + errorInvalidPortraitSettings: 'Пресеты портретов этой школы заданы неверно.', catalogLoadFailed: 'Не удалось загрузить каталог модов.', modsTitle: 'Моды', @@ -112,6 +113,10 @@ const ru = { editMap: 'Редактировать карту', mapDefaultHint: 'Будет использована карта по умолчанию.', mapEditedHint: 'Карта изменена.', + portraitPresets: 'Пресеты портретов', + editPortraitPresets: 'Настроить модели', + portraitPresetsHint: 'Модель и промпты сохранятся в этой школе.', + portraitPresetsEdited: 'Заданы для этой школы.', schoolSeed: 'Сид {seed}', schoolSeedLabel: 'Сид', schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.', @@ -195,7 +200,13 @@ const ru = { peopleTabApparel: 'Одежда', peopleTabCarry: 'Ноша', peopleTabNow: 'Сейчас', + peopleTabConnections: 'Связи', peopleTabPortrait: 'Портрет', + peopleConnectionsFriends: 'Друзья', + peopleConnectionsEnemies: 'Враги', + peopleConnectionsSearch: 'Поиск по связям', + peopleConnectionsEmpty: 'Нет других связей.', + peopleOpinionValue: '{label} ({value})', peopleAtHome: 'дома', peopleApparelFit: 'Уместность: {value}', peopleLockerYes: 'Есть шкафчик', @@ -446,6 +457,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', @@ -458,6 +470,10 @@ 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.', schoolSeed: 'Seed {seed}', schoolSeedLabel: 'Seed', schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.', @@ -541,7 +557,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 3eafa13..aa7cbd4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -71,6 +71,7 @@ export interface CreateSchoolOptions { readonly countryId?: string; readonly nativeLanguage?: string; readonly seed?: number; + readonly portraitSettings?: SwarmUiSettingsFile; } export async function createSchool( @@ -89,6 +90,7 @@ export async function createSchool( countryId: extras.countryId ?? null, nativeLanguage: extras.nativeLanguage ?? null, seed: extras.seed ?? null, + portraitSettings: extras.portraitSettings ?? null, }), }); } @@ -277,6 +279,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 { @@ -316,6 +340,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/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 aeae31c..73d97c0 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -46,10 +46,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', @@ -66,7 +62,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; @@ -75,7 +70,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, @@ -99,7 +94,6 @@ export class MainMenu { this.mineHeading.textContent = t('schoolsMine'); this.othersHeading.textContent = t('schoolsOthers'); 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/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.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.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 59758ba..13d1f65 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, @@ -112,7 +113,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/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 1a9aeeb..e64197f 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -76,6 +76,20 @@ internal static class SchoolEndpoints return Results.Unauthorized(); } + 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), @@ -85,6 +99,7 @@ internal static class SchoolEndpoints request.NativeLanguage, request.Seed, owner, + portraitSettings, NewCompletion()); commands.Enqueue(command); @@ -932,7 +947,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 d8af893..4158b3b 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -20,6 +20,7 @@ internal abstract record GameCommand string? NativeLanguage, int? Seed, string Owner, + 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 12b33a8..f857e3c 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. @@ -453,7 +468,22 @@ 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, owner: command.Owner); + 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, + owner: command.Owner, + portraitSettings: portrait); Track(worker); worker.Start(); @@ -670,7 +700,8 @@ internal sealed class GameLoopService( createSeed: null, save.Presence, save.DressRules, - save.Owner); + save.Owner, + save.PortraitSettings); worker.Start(); try @@ -727,7 +758,8 @@ internal sealed class GameLoopService( int? createSeed = null, IReadOnlyList? presence = null, SchoolDressRules? dressRules = null, - string? owner = null) => + string? owner = null, + SwarmUiConfigFile? portraitSettings = null) => new( id, name, @@ -744,6 +776,7 @@ internal sealed class GameLoopService( presence, dressRules, owner, + portraitSettings, _options, clients, metrics, diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 5c19a6f..c6568c9 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) @@ -376,8 +377,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) : [], @@ -385,6 +386,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, @@ -403,4 +417,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/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 7036150..038c124 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -38,6 +38,9 @@ internal sealed class SchoolSave /// Normalized player name. Missing or blank means ownerless. public string? Owner { 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. @@ -176,6 +179,7 @@ internal sealed class SchoolStore Presence = save.Presence, DressRules = save.DressRules, Owner = save.Owner, + PortraitSettings = save.PortraitSettings, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index c3c74da..17f921a 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -39,6 +39,7 @@ internal sealed class SchoolWorker private readonly IReadOnlyList? _savedPresence; private readonly SchoolDressRules? _savedDressRules; private readonly string? _owner; + private readonly SwarmUiConfigFile? _portraitSettings; private readonly Action _onFailed; private readonly int _id; @@ -76,6 +77,7 @@ internal sealed class SchoolWorker IReadOnlyList? savedPresence, SchoolDressRules? savedDressRules, string? owner, + SwarmUiConfigFile? portraitSettings, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, @@ -99,6 +101,7 @@ internal sealed class SchoolWorker _savedPresence = savedPresence; _savedDressRules = savedDressRules; _owner = string.IsNullOrWhiteSpace(owner) ? null : owner; + _portraitSettings = portraitSettings; _options = options; _clients = clients; _metrics = metrics; @@ -116,6 +119,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); @@ -983,6 +989,12 @@ internal sealed class SchoolWorker RequireKnownApparel(catalog, roster, applicants); } + if (OpinionGenerator.NeedsFamilyOpinions(roster)) + { + roster = OpinionGenerator.SeedFamily(catalog, roster); + generated = true; + } + roster = LockerAssigner.Apply(catalog, map, roster); if (!RosterFit.Matches(roster, demand)) @@ -1069,6 +1081,7 @@ internal sealed class SchoolWorker Presence = school.CapturePresence(), DressRules = school.DressRules, Owner = _owner, + 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/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 ac97782..c587ff1 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -180,6 +180,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 0c5630c..affb2e6 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -180,6 +180,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 70b2ad9..e822238 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.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 2621a28..54a1781 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -592,26 +592,7 @@ public class SchoolApiTests(AppHostFixture fixture) internal static async Task ResetAsync(HttpClient client) { - await LoginAsync(client); - - var state = await GetSchoolsAsync(client); - - foreach (var school in state.Schools) - { - using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); - } - - foreach (var school in state.Others) - { - using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); - if (response.StatusCode == HttpStatusCode.Forbidden) - { - continue; - } - - response.EnsureSuccessStatusCode(); - } + await WipeAllSavesAsync(client); } internal static async Task WipeAllSavesAsync(HttpClient client) @@ -731,7 +712,16 @@ public class SchoolApiTests(AppHostFixture fixture) return payload.Path; } - private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage, string? Owner); + private sealed record SchoolSaveFile( + string? CountryId, + string? ClimatePresetId, + string? NativeLanguage, + string? Owner, + 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.AppHost.Tests/SchoolOwnerApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs index 81618f7..f1aadcf 100644 --- a/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs @@ -71,6 +71,9 @@ public class SchoolOwnerApiTests(AppHostFixture fixture) { client.Dispose(); } + + using var cleanup = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.WipeAllSavesAsync(cleanup); } } 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.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); + } } 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); + } +}