Merge main into phase/39-school-owners.

Keep school owners and phase 41 opinions/portraits together; ResetAsync wipes all saves so shared AppHost tests stay isolated.
This commit is contained in:
Leonid Pershin
2026-08-20 08:36:13 +03:00
44 changed files with 1388 additions and 85 deletions
+70
View File
@@ -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/<slug>.md`. «Вне очереди» номер не занимает.
Номер фазы: максимум по `docs/phases/*.md` и индексу, плюс один, затем
`git branch --list "phase/<N>-*"`. Занято — следующий. Гонку ловят ветки, не
индекс: у новой фазы на `main` строки ещё нет.
## 3. Документы
Как у соседней фазы, не новый жанр. Каркас тот же: Зависимости, Зачем, Задачи,
Тесты без которых фаза не закрыта, Критерий готовности, Стоп.
«Тесты…» — контракт для `/phase-review`: каждый пункт станет отдельным тестом
по Testing policy в `AGENTS.md`. Дизайн: зачем; «Было / Стало / Почему», если
меняется принятое; что не входит. Индекс — новая строка, статус ⬜. Новый срез,
куда ходят люди — строка в Orientation `AGENTS.md`.
Протокол, HTTP, сейв — назвать явно. Версию не бампать: это коммит с кодом.
Покажи срез, фазы и Стоп. Просил только документы — здесь конец. Просил сделать
— сразу шаг 4.
## 4. Работа
Прочитай [phase-work](../phase-work/SKILL.md) и следуй ему.
Отличия: фазу ты завёл сам; заявка `git branch phase/<N>-<slug>` **до** кода;
новые файлы и 🔄 живут на этой ветке. Один цикл — одна фаза. «Сделай всё» —
по порядку, каждая своим циклом `/phase-work`. Три фазы в одну ветку не паковать.
## Отчёт
Форма и куда легли документы; фаза и ветка; тесты списком; что прогнал; что
осталось ⬜; слита ли ветка в `main`.
+1
View File
@@ -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 3839 | | login, who owns a school, watching others | [`docs/design/session.md`](docs/design/session.md) — slice 8, phases 3839 |
| clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`docs/design/session.md`](docs/design/session.md) — phase 40 | | 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 4147 | | talks, opinions, fights, romance pack | [`docs/design/social.md`](docs/design/social.md) — slice 9, phases 4147 |
| a task that is not in any slice | `.claude/skills/side-work` — discuss, document in «Вне очереди» or a new named slice, then implement |
## Commands ## Commands
+1
View File
@@ -128,6 +128,7 @@ Welcome не трогаем, версию не бампим. Глобальны
- пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение); - пауза, скорость, пропуск пустого времени — кадр сокета игнорируется (не закрывает соединение);
- удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`; - удалить, нанять, назначить, сменить правила, закрепить урок — HTTP `403` `not-owner`;
- генерировать портреты — **можно**: пресеты лежат на школе, модель одна и та же;
- видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да. - видеть вкладку «Управление» — клиент её не монтирует. Карта и люди — да, карточка — да.
Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько Хозяин, который смотрит свою, ничего не теряет. Несколько гостей на одну школу — несколько
+2 -1
View File
@@ -21,7 +21,8 @@
`others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine` `others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine`
- [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый) - [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый)
- [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный. - [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный.
`OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят `OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят.
**Исключение:** `POST .../portrait` — гость генерирует теми же пресетами школы; это не управление
- [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет - [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет
- [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск). - [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск).
Карта и люди остаются Карта и люди остаются
+13 -13
View File
@@ -12,25 +12,25 @@
## Задачи ## Задачи
- [ ] Мнение A→B: −100…100, несимметрично, ноль не хранить - [x] Мнение A→B: −100…100, несимметрично, ноль не хранить
- [ ] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам - [x] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам
- [ ] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных - [x] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных
- [ ] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; - [x] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю;
дошли до нуля — запись выкинуть дошли до нуля — запись выкинуть
- [ ] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи - [x] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи
после первой загрузки-переписи после первой загрузки-переписи
- [ ] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым - [x] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым
- [ ] Игрок с вкладки ничего не меняет - [x] Игрок с вкладки ничего не меняет
- [ ] `docs/protocol.md` — HTTP карточки - [x] `docs/protocol.md` — HTTP карточки
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи - [x] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи
- [ ] A→B и B→A могут различаться - [x] A→B и B→A могут различаться
- [ ] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) - [x] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось)
ближе к нулю, семейное — к старту, не к нулю ближе к нулю, семейное — к старту, не к нулю
- [ ] Карточка без отдельного запроса «всех пар школы»: только этот человек - [x] Карточка без отдельного запроса «всех пар школы»: только этот человек
- [ ] Клиентский тест: вкладка «Связи» не монтирует лог - [x] Клиентский тест: вкладка «Связи» не монтирует лог
## Критерий готовности ## Критерий готовности
+3 -3
View File
@@ -173,7 +173,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [39. Хозяин школы](39-school-owners.md) | | `owner`, 2 слота на игрока, мои / чужие, гость без управления | | [39. Хозяин школы](39-school-owners.md) | 🔄 | `owner`, 2 слота на игрока, мои / чужие, гость без управления |
39 стоит на 38. 39 стоит на 38.
@@ -194,13 +194,13 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [41. Мнения и «Связи»](41-opinions.md) | 🔄 | −100…100, семья-старт, вкладка карточки | | [41. Мнения и «Связи»](41-opinions.md) | | −100…100, семья-старт, вкладка карточки |
**Этап B — разговоры.** Кружок, язык, шёпот. **Этап B — разговоры.** Кружок, язык, шёпот.
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [42. Темы и кружки](42-talk-circles.md) | | `TopicDef`, 2–4 человека, учительская, телефон, вместе | | [42. Темы и кружки](42-talk-circles.md) | 🔄 | `TopicDef`, 2–4 человека, учительская, телефон, вместе |
| [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | | [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка |
42 стоит на 41; 43 — на 42. 42 стоит на 41; 43 — на 42.
+45 -7
View File
@@ -174,7 +174,8 @@ Body:
"map": null, "map": null,
"countryId": "Russia", "countryId": "Russia",
"nativeLanguage": null, "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`. one from the school seed. An id that is not in the country returns `400` `unknown-native-language`.
`seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and `seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and
the server rolls one. Existing saves keep the seed already stored in the people file. the server rolls one. Existing saves keep the seed already stored in the people file.
`portraitSettings` is the SwarmUI preset file for **this** school (same shape as
`GET /api/settings/swarmui`). Omit it (or send `null`) to copy the server template at create.
Invalid presets return `400` `invalid-portrait-settings`. Generation later uses this copy, not
the global file, so a guest watching the school draws with the same model.
| Status | Meaning | | Status | Meaning |
| --- | --- | | --- | --- |
@@ -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` `invalid-catalog` | The selected packs could not be loaded. |
| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. | | `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. |
| `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. | | `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. |
| `400` `invalid-portrait-settings` | `portraitSettings` failed validation (empty presets, bad age rule, out of range). |
| `409` `school-limit-reached` | This player already owns `maxSchools` schools. | | `409` `school-limit-reached` | This player already owns `maxSchools` schools. |
| `409` `server-full` | The process already runs `maxSchoolsTotal` 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 They are filled on the HTTP thread after the worker returns the card; generation does
not happen on this request. 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 ```json
{ {
"id": "f0.c0", "id": "f0.c0",
@@ -372,7 +382,34 @@ not happen on this request.
"hasAvatar": false, "hasAvatar": false,
"hasCustom": false, "hasCustom": false,
"hasFullBody": 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` ### `GET /api/settings/swarmui`
Returns the editable SwarmUI preset file (`swarmui.json`): named presets (model, steps, sampler, Returns the **default** SwarmUI preset template (`swarmui.json`): named presets (model, steps,
LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules` mapping age bands to presets. sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies
Portrait generation resolves the preset from the person's age before building the prompt. this into its save as `portraitSettings`. Living schools generate from that copy, not from this
file.
### `PUT /api/settings/swarmui` ### `PUT /api/settings/swarmui`
Replaces the preset file after validation. Invalid preset ids, age rules or numeric ranges return Replaces the default template after validation. Invalid preset ids, age rules or numeric ranges
`400` `invalid-body`. Changes apply immediately to new portrait generations. return `400` `invalid-body`. Already-created schools keep the copy they were created with.
### `GET /api/settings/swarmui/discovery` ### `GET /api/settings/swarmui/discovery`
+22
View File
@@ -100,6 +100,7 @@ const ru = {
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownCountry: 'Выбранная страна не найдена.', errorUnknownCountry: 'Выбранная страна не найдена.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в эту страну.',
errorInvalidPortraitSettings: 'Пресеты портретов этой школы заданы неверно.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.', catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды', modsTitle: 'Моды',
@@ -112,6 +113,10 @@ const ru = {
editMap: 'Редактировать карту', editMap: 'Редактировать карту',
mapDefaultHint: 'Будет использована карта по умолчанию.', mapDefaultHint: 'Будет использована карта по умолчанию.',
mapEditedHint: 'Карта изменена.', mapEditedHint: 'Карта изменена.',
portraitPresets: 'Пресеты портретов',
editPortraitPresets: 'Настроить модели',
portraitPresetsHint: 'Модель и промпты сохранятся в этой школе.',
portraitPresetsEdited: 'Заданы для этой школы.',
schoolSeed: 'Сид {seed}', schoolSeed: 'Сид {seed}',
schoolSeedLabel: 'Сид', schoolSeedLabel: 'Сид',
schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.', schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.',
@@ -195,7 +200,13 @@ const ru = {
peopleTabApparel: 'Одежда', peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша', peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас', peopleTabNow: 'Сейчас',
peopleTabConnections: 'Связи',
peopleTabPortrait: 'Портрет', peopleTabPortrait: 'Портрет',
peopleConnectionsFriends: 'Друзья',
peopleConnectionsEnemies: 'Враги',
peopleConnectionsSearch: 'Поиск по связям',
peopleConnectionsEmpty: 'Нет других связей.',
peopleOpinionValue: '{label} ({value})',
peopleAtHome: 'дома', peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}', peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик', peopleLockerYes: 'Есть шкафчик',
@@ -446,6 +457,7 @@ const en: Messages = {
errorInvalidCatalog: 'The selected packs could not be loaded.', errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownCountry: 'The selected country is not in the catalog.', errorUnknownCountry: 'The selected country is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that country.', 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.', catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods', modsTitle: 'Mods',
@@ -458,6 +470,10 @@ const en: Messages = {
editMap: 'Edit map', editMap: 'Edit map',
mapDefaultHint: 'The default map will be used.', mapDefaultHint: 'The default map will be used.',
mapEditedHint: 'The map has been edited.', 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}', schoolSeed: 'Seed {seed}',
schoolSeedLabel: 'Seed', schoolSeedLabel: 'Seed',
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.', schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
@@ -541,7 +557,13 @@ const en: Messages = {
peopleTabApparel: 'Clothes', peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried', peopleTabCarry: 'Carried',
peopleTabNow: 'Now', peopleTabNow: 'Now',
peopleTabConnections: 'Connections',
peopleTabPortrait: 'Portrait', peopleTabPortrait: 'Portrait',
peopleConnectionsFriends: 'Friends',
peopleConnectionsEnemies: 'Enemies',
peopleConnectionsSearch: 'Search connections',
peopleConnectionsEmpty: 'No other connections.',
peopleOpinionValue: '{label} ({value})',
peopleAtHome: 'home', peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}', peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker', peopleLockerYes: 'Has a locker',
+25
View File
@@ -71,6 +71,7 @@ export interface CreateSchoolOptions {
readonly countryId?: string; readonly countryId?: string;
readonly nativeLanguage?: string; readonly nativeLanguage?: string;
readonly seed?: number; readonly seed?: number;
readonly portraitSettings?: SwarmUiSettingsFile;
} }
export async function createSchool( export async function createSchool(
@@ -89,6 +90,7 @@ export async function createSchool(
countryId: extras.countryId ?? null, countryId: extras.countryId ?? null,
nativeLanguage: extras.nativeLanguage ?? null, nativeLanguage: extras.nativeLanguage ?? null,
seed: extras.seed ?? null, seed: extras.seed ?? null,
portraitSettings: extras.portraitSettings ?? null,
}), }),
}); });
} }
@@ -277,6 +279,28 @@ export interface PersonRel {
readonly id: string; readonly id: string;
readonly fullName: string; readonly fullName: string;
readonly female: boolean; 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 { export interface PersonCard {
@@ -316,6 +340,7 @@ export interface PersonCard {
readonly hasCustom: boolean; readonly hasCustom: boolean;
readonly hasFullBody: boolean; readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null; readonly customPortraitPrompt: string | null;
readonly connections: PersonConnections | null;
} }
export interface WornItem { export interface WornItem {
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
fetchCatalog, fetchCatalog,
fetchMods, fetchMods,
fetchSwarmUiSettings,
type CatalogResponse, type CatalogResponse,
type MapLayout, type MapLayout,
type School, type School,
@@ -20,6 +21,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual, ...actual,
fetchCatalog: vi.fn(), fetchCatalog: vi.fn(),
fetchMods: vi.fn(), fetchMods: vi.fn(),
fetchSwarmUiSettings: vi.fn(),
}; };
}); });
@@ -108,6 +110,31 @@ describe('createSchoolDialog', () => {
{ id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] }, { id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] },
]); ]);
vi.mocked(fetchCatalog).mockResolvedValue(catalog()); vi.mocked(fetchCatalog).mockResolvedValue(catalog());
vi.mocked(fetchSwarmUiSettings).mockReset();
vi.mocked(fetchSwarmUiSettings).mockResolvedValue({
activePresetId: 'default',
presets: [
{
id: 'default',
label: 'Default',
model: 'template.safetensors',
steps: 4,
cfgScale: 1,
clipSkip: 1,
sampler: 'euler',
scheduler: 'normal',
seed: -1,
positive: 'base',
negative: 'neg',
positiveLoras: [],
negativeLoras: [],
avatar: { width: 512, height: 512, positive: '' },
custom: { width: 512, height: 512, positive: '' },
fullBody: { width: 512, height: 512, positive: '' },
},
],
ageRules: [],
});
}); });
afterEach(() => { afterEach(() => {
@@ -291,4 +318,22 @@ describe('map reset from the create editor', () => {
await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint'))); await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint')));
void opened; void opened;
}); });
it('shows a portrait-preset button that stores settings on the school', async () => {
const opened = createSchoolDialog({
defaultStartDate: new Date('2024-09-01T00:00:00.000Z'),
suggestName: async () => 'North',
create: async () => school(),
});
await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled());
const dialog = document.querySelector('dialog');
if (dialog === null) {
throw new Error('create dialog is missing');
}
const presets = byText(dialog, 'button', t('editPortraitPresets'));
expect(presets.nextElementSibling?.textContent).toBe(t('portraitPresetsHint'));
await vi.waitFor(() => expect(fetchSwarmUiSettings).toHaveBeenCalled());
void opened;
});
}); });
@@ -2,10 +2,12 @@ import {
ApiError, ApiError,
fetchCatalog, fetchCatalog,
fetchMods, fetchMods,
fetchSwarmUiSettings,
type CatalogResponse, type CatalogResponse,
type CreateSchoolOptions as CreateExtras, type CreateSchoolOptions as CreateExtras,
type MapLayout, type MapLayout,
type School, type School,
type SwarmUiSettingsFile,
} from '../net/api.ts'; } from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts'; import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
@@ -13,6 +15,7 @@ import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { mapEditorDialog } from './mapEditorDialog.ts'; import { mapEditorDialog } from './mapEditorDialog.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
interface CreateSchoolOptions { interface CreateSchoolOptions {
/** Prefilled start of the school year, straight from the server config. */ /** Prefilled start of the school year, straight from the server config. */
@@ -34,6 +37,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
let currentMap: MapLayout | null = null; let currentMap: MapLayout | null = null;
let countryId: string | null = null; let countryId: string | null = null;
let nativeLanguageId: string | null = null; let nativeLanguageId: string | null = null;
let portraitSettings: SwarmUiSettingsFile | null = null;
let portraitEdited = false;
const modsField = el('div', { class: 'field' }); const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' }); const modsLabel = el('span', { class: 'field__label' });
@@ -80,6 +85,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('div', { class: 'field__row' }, editMapButton, mapHint), el('div', { class: 'field__row' }, editMapButton, mapHint),
); );
const presetsLabel = el('span', { class: 'field__label' });
const presetsButton = el('button', { class: 'button', type: 'button' });
const presetsHint = el('p', { class: 'hint' });
const presetsField = el(
'div',
{ class: 'field' },
presetsLabel,
el('div', { class: 'field__row' }, presetsButton, presetsHint),
);
const nameInput = el('input', { class: 'input', type: 'text' }); const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40; nameInput.maxLength = 40;
nameInput.required = true; nameInput.required = true;
@@ -113,6 +128,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
countryField, countryField,
nativeField, nativeField,
mapField, mapField,
presetsField,
seedField, seedField,
error, error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton), el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
@@ -127,6 +143,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
randomButton.toggleAttribute('disabled', value); randomButton.toggleAttribute('disabled', value);
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1); nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
editMapButton.toggleAttribute('disabled', waiting); editMapButton.toggleAttribute('disabled', waiting);
presetsButton.toggleAttribute('disabled', waiting);
seedInput.toggleAttribute('disabled', value); seedInput.toggleAttribute('disabled', value);
}; };
@@ -135,6 +152,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
error.hidden = false; error.hidden = false;
}; };
const paintPresetsHint = (): void => {
presetsHint.textContent = portraitEdited ? t('portraitPresetsEdited') : t('portraitPresetsHint');
};
const paintMapHint = (): void => { const paintMapHint = (): void => {
if (catalog === null || currentMap === null) { if (catalog === null || currentMap === null) {
mapHint.textContent = ''; mapHint.textContent = '';
@@ -253,6 +274,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
startLabel.textContent = t('gameStart'); startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle'); mapLabel.textContent = t('mapEditorTitle');
editMapButton.textContent = t('editMap'); editMapButton.textContent = t('editMap');
presetsLabel.textContent = t('portraitPresets');
presetsButton.textContent = t('editPortraitPresets');
paintPresetsHint();
nameInput.placeholder = t('schoolNamePlaceholder'); nameInput.placeholder = t('schoolNamePlaceholder');
randomButton.textContent = t('randomName'); randomButton.textContent = t('randomName');
randomButton.title = t('randomNameTitle'); randomButton.title = t('randomNameTitle');
@@ -279,6 +303,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
}); });
}); });
presetsButton.addEventListener('click', () => {
if (busy) {
return;
}
void swarmUiSettingsDialog(portraitSettings ?? undefined).then((next) => {
if (next === null) {
return;
}
portraitSettings = next;
portraitEdited = true;
paintPresetsHint();
presetsButton.focus();
});
});
nativeRandomButton.addEventListener('click', () => { nativeRandomButton.addEventListener('click', () => {
if (busy || catalog === null) { if (busy || catalog === null) {
return; return;
@@ -335,6 +376,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
countryId: countryId ?? undefined, countryId: countryId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined, nativeLanguage: nativeLanguageId ?? undefined,
seed: seed ?? undefined, seed: seed ?? undefined,
portraitSettings: portraitSettings ?? undefined,
}) })
.then((school) => modal.close(school)) .then((school) => modal.close(school))
.catch((reason: unknown) => { .catch((reason: unknown) => {
@@ -345,6 +387,15 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
modal.element.append(title, form); modal.element.append(title, form);
void paintMods(); void paintMods();
void fetchSwarmUiSettings()
.then((settings) => {
if (portraitSettings === null) {
portraitSettings = settings;
}
})
.catch(() => {
/* Server copies swarmui.json at create if the form sends nothing. */
});
return modal.open(nameInput); return modal.open(nameInput);
} }
@@ -405,6 +456,8 @@ function describe(reason: unknown): string {
return t('errorUnknownCountry'); return t('errorUnknownCountry');
case 'unknown-native-language': case 'unknown-native-language':
return t('errorUnknownNativeLanguage'); return t('errorUnknownNativeLanguage');
case 'invalid-portrait-settings':
return t('errorInvalidPortraitSettings');
default: default:
return reason.message; return reason.message;
} }
+1 -7
View File
@@ -46,10 +46,6 @@ export class MainMenu {
class: 'button button--primary', class: 'button button--primary',
type: 'button', type: 'button',
}); });
private readonly settingsButton = el('button', {
class: 'button',
type: 'button',
});
private readonly logoutButton = el('button', { private readonly logoutButton = el('button', {
class: 'button', class: 'button',
type: 'button', type: 'button',
@@ -66,7 +62,6 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) { constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog()); this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.logoutButton.addEventListener('click', () => this.options.onLogout()); this.logoutButton.addEventListener('click', () => this.options.onLogout());
this.status.hidden = true; this.status.hidden = true;
@@ -75,7 +70,7 @@ export class MainMenu {
'header', 'header',
{ class: 'screen__header' }, { class: 'screen__header' },
this.title, this.title,
el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton), el('div', { class: 'screen__actions' }, this.logoutButton, this.createButton),
), ),
this.limitHint, this.limitHint,
this.status, this.status,
@@ -99,7 +94,6 @@ export class MainMenu {
this.mineHeading.textContent = t('schoolsMine'); this.mineHeading.textContent = t('schoolsMine');
this.othersHeading.textContent = t('schoolsOthers'); this.othersHeading.textContent = t('schoolsOthers');
this.createButton.textContent = t('createSchool'); this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings');
this.logoutButton.textContent = t('sessionLogout'); this.logoutButton.textContent = t('sessionLogout');
this.emptyHint.textContent = t('emptySchools'); this.emptyHint.textContent = t('emptySchools');
@@ -78,6 +78,7 @@ function personCard(): PersonCard {
hasCustom: false, hasCustom: false,
hasFullBody: false, hasFullBody: false,
customPortraitPrompt: null, customPortraitPrompt: null,
connections: null,
}; };
} }
@@ -64,6 +64,25 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasCustom: false, hasCustom: false,
hasFullBody: false, hasFullBody: false,
customPortraitPrompt: null, customPortraitPrompt: null,
connections: {
family: {
parents: [
{
id: 'f0.p0',
fullName: 'Иванова Ольга',
female: true,
opinion: 75,
opinionLabel: 'близкие друзья',
},
],
children: [],
siblings: [],
partners: [],
},
friends: [],
enemies: [],
others: [],
},
...overrides, ...overrides,
}; };
} }
@@ -261,6 +280,17 @@ describe('renderPersonCard', () => {
expect(root.querySelector('.people__log-tools')).toBeNull(); 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', () => { it('pages the log on the now tab', () => {
setLocale('ru'); setLocale('ru');
const onLogPage = vi.fn(); const onLogPage = vi.fn();
+130 -3
View File
@@ -4,13 +4,14 @@ import type {
PersonLogDir, PersonLogDir,
PersonLogPage, PersonLogPage,
PersonLogQuery, PersonLogQuery,
PersonOpinionLink,
PersonRel, PersonRel,
PersonRole, PersonRole,
} from '../net/api.ts'; } from '../net/api.ts';
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { clear, el } from './dom.ts';
const ROLE_KEYS: Record<PersonRole, MessageKey> = { const ROLE_KEYS: Record<PersonRole, MessageKey> = {
student: 'peopleRoleStudent', student: 'peopleRoleStudent',
@@ -18,7 +19,7 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
parent: 'peopleRoleParent', parent: 'peopleRoleParent',
}; };
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait'; export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait';
export interface RenderPersonCardOptions { export interface RenderPersonCardOptions {
readonly place?: string; readonly place?: string;
@@ -110,6 +111,7 @@ export function renderPersonCard(
apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }), apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }),
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }), carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }), 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' } }), portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
}; };
@@ -118,6 +120,7 @@ export function renderPersonCard(
apparel: 'peopleTabApparel', apparel: 'peopleTabApparel',
carry: 'peopleTabCarry', carry: 'peopleTabCarry',
now: 'peopleTabNow', now: 'peopleTabNow',
connections: 'peopleTabConnections',
portrait: 'peopleTabPortrait', portrait: 'peopleTabPortrait',
}; };
@@ -142,6 +145,7 @@ export function renderPersonCard(
fillApparel(panels.apparel, card); fillApparel(panels.apparel, card);
fillCarry(panels.carry, card); fillCarry(panels.carry, card);
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
fillConnections(panels.connections, card, onRelative);
fillPortrait(panels.portrait, card, options); fillPortrait(panels.portrait, card, options);
const showTab = (next: PersonCardTab): void => { const showTab = (next: PersonCardTab): void => {
@@ -160,7 +164,7 @@ export function renderPersonCard(
showTab(tab); 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 { function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
@@ -256,6 +260,46 @@ 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( function fillNow(
parent: HTMLElement, parent: HTMLElement,
card: PersonCard, card: PersonCard,
@@ -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);
}
@@ -1,9 +1,7 @@
import { import {
ApiError,
fetchGameStatus, fetchGameStatus,
fetchSwarmUiDiscovery, fetchSwarmUiDiscovery,
fetchSwarmUiSettings, fetchSwarmUiSettings,
saveSwarmUiSettings,
type SwarmUiDiscovery, type SwarmUiDiscovery,
type SwarmUiLoraEntry, type SwarmUiLoraEntry,
type SwarmUiPresetDefinition, type SwarmUiPresetDefinition,
@@ -13,8 +11,8 @@ import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
export function swarmUiSettingsDialog(): Promise<boolean> { export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile | null> {
const modal = new Modal<boolean>(false); const modal = new Modal<SwarmUiSettingsFile | null>(null);
const error = el('p', { class: 'dialog__error' }); const error = el('p', { class: 'dialog__error' });
error.hidden = true; error.hidden = true;
@@ -28,7 +26,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] }; let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
let editingPresetId = ''; let editingPresetId = '';
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) }); const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
const saveButton = el('button', { class: 'button button--primary', type: 'submit' }); const saveButton = el('button', { class: 'button button--primary', type: 'submit' });
const addPresetButton = el('button', { class: 'button button--small', type: 'button' }); const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' }); const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' });
@@ -116,7 +114,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
localize(); localize();
try { try {
const [settings, gameStatus, lists] = await Promise.all([ const [settings, gameStatus, lists] = await Promise.all([
fetchSwarmUiSettings(), initial === undefined ? fetchSwarmUiSettings() : Promise.resolve(structuredClone(initial)),
fetchGameStatus(), fetchGameStatus(),
fetchSwarmUiDiscovery(), fetchSwarmUiDiscovery(),
]); ]);
@@ -153,14 +151,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
syncActivePresetFromForm(); syncActivePresetFromForm();
saveButton.disabled = true; saveButton.disabled = true;
error.hidden = true; error.hidden = true;
try { modal.close(structuredClone(config));
await saveSwarmUiSettings(config);
modal.close(true);
} catch (err) {
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
} finally {
saveButton.disabled = false;
}
} }
function showError(message: string): void { function showError(message: string): void {
+40
View File
@@ -308,6 +308,38 @@ public sealed class BehaviorDef : Def
/// <summary>Game minutes for <c>ChangeClothes*</c> actions.</summary> /// <summary>Game minutes for <c>ChangeClothes*</c> actions.</summary>
public float ChangeClothesMinutes { get; init; } = 5f; public float ChangeClothesMinutes { get; init; } = 5f;
/// <summary>Parent → child start. Child → parent uses <see cref="OpinionChildToParentStart"/>.</summary>
public int OpinionParentToChildStart { get; init; } = 85;
public int OpinionChildToParentStart { get; init; } = 75;
public int OpinionSiblingStart { get; init; } = 45;
public int OpinionPartnerStart { get; init; } = 65;
/// <summary>Points each work morning moves an opinion toward family basis or zero.</summary>
public int OpinionDriftPerMorning { get; init; } = 3;
/// <summary>How many non-family friends or enemies the card lists at the top.</summary>
public int OpinionTopCount { get; init; } = 5;
/// <summary>
/// Caption bands for 100…100. Highest <see cref="OpinionBand.Min"/> the value still meets wins.
/// Empty falls back to <see cref="DefaultOpinionBands"/>.
/// </summary>
public IReadOnlyList<OpinionBand> OpinionBands { get; init; } = DefaultOpinionBands;
public static IReadOnlyList<OpinionBand> 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<ApparelConditionBand> DefaultConditionBands { get; } = public static IReadOnlyList<ApparelConditionBand> DefaultConditionBands { get; } =
[ [
new() { Min = 0.75f, Id = "ApparelConditionIntact" }, new() { Min = 0.75f, Id = "ApparelConditionIntact" },
@@ -325,6 +357,14 @@ public sealed class ApparelConditionBand
public required string Id { get; init; } public required string Id { get; init; }
} }
/// <summary>One caption on an opinion value. <see cref="Id"/> is a locale key, not a Def.</summary>
public sealed class OpinionBand
{
public int Min { get; init; }
public required string Id { get; init; }
}
public enum BodyAttributeKind public enum BodyAttributeKind
{ {
Number, Number,
+97
View File
@@ -0,0 +1,97 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Seeds family opinions at generation or when an old save lacks them.</summary>
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<string, int>)
{
return person;
}
return person with
{
Opinions = new Dictionary<string, int>(person.Opinions, StringComparer.Ordinal),
};
}
private static Person SeedPersonFamilyOpinions(
Roster roster,
BehaviorDef rules,
IReadOnlyList<Person> 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;
}
}
+30
View File
@@ -0,0 +1,30 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Threshold captions live in the catalog. The client draws what it is told.</summary>
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));
}
+147
View File
@@ -0,0 +1,147 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Sparse A→B opinions on a person. Zero is not stored.</summary>
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<string, int> 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<string, int> 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<string> 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<string>(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<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}
+3
View File
@@ -52,6 +52,9 @@ public sealed record Person
/// <summary>Changing-room node when this pupil won a locker slot. Null for staff and the rest.</summary> /// <summary>Changing-room node when this pupil won a locker slot. Null for staff and the rest.</summary>
public string? LockerRoomId { get; init; } public string? LockerRoomId { get; init; }
/// <summary>What this person thinks of others, A→B. Sparse; zero is not stored.</summary>
public IReadOnlyDictionary<string, int> Opinions { get; init; } = new Dictionary<string, int>(StringComparer.Ordinal);
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
} }
+2 -1
View File
@@ -44,7 +44,8 @@ public static class RosterGenerator
} }
var classes = FillClasses(demand.Classes, people); 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);
} }
/// <summary> /// <summary>
+21 -2
View File
@@ -81,7 +81,8 @@ internal sealed record PersonCardResponse(
bool HasAvatar = false, bool HasAvatar = false,
bool HasCustom = false, bool HasCustom = false,
bool HasFullBody = false, bool HasFullBody = false,
string? CustomPortraitPrompt = null); string? CustomPortraitPrompt = null,
PersonConnectionsResponse? Connections = null);
internal sealed record WornItemResponse( internal sealed record WornItemResponse(
string DefName, string DefName,
@@ -112,7 +113,25 @@ internal sealed record PersonFamilyResponse(
IReadOnlyList<PersonRelResponse> Siblings, IReadOnlyList<PersonRelResponse> Siblings,
IReadOnlyList<PersonRelResponse> Partners); IReadOnlyList<PersonRelResponse> 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<PersonOpinionLinkResponse> Friends,
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
IReadOnlyList<PersonOpinionLinkResponse> Others);
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People); internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
+17 -1
View File
@@ -76,6 +76,20 @@ internal static class SchoolEndpoints
return Results.Unauthorized(); 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( var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty, request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
@@ -85,6 +99,7 @@ internal static class SchoolEndpoints
request.NativeLanguage, request.NativeLanguage,
request.Seed, request.Seed,
owner, owner,
portraitSettings,
NewCompletion<SchoolCreationOutcome>()); NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command); commands.Enqueue(command);
@@ -932,7 +947,8 @@ internal sealed record CreateSchoolRequest(
MapLayout? Map, MapLayout? Map,
string? CountryId, string? CountryId,
string? NativeLanguage, string? NativeLanguage,
int? Seed); int? Seed,
SwarmUiConfigFile? PortraitSettings);
internal sealed record SchoolResponse( internal sealed record SchoolResponse(
int Id, int Id,
@@ -8,6 +8,7 @@ internal static class SettingsEndpoints
{ {
var settings = endpoints.MapGroup("/api/settings"); var settings = endpoints.MapGroup("/api/settings");
// Default template copied into a new school. Living schools generate from their own copy.
settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current)) settings.MapGet("/swarmui", (SwarmUiSettingsStore store) => Results.Ok(store.Current))
.WithName("GetSwarmUiSettings"); .WithName("GetSwarmUiSettings");
+1
View File
@@ -20,6 +20,7 @@ internal abstract record GameCommand
string? NativeLanguage, string? NativeLanguage,
int? Seed, int? Seed,
string Owner, string Owner,
SwarmUiConfigFile? PortraitSettings,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand; TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+36 -3
View File
@@ -20,6 +20,7 @@ internal sealed class GameLoopService(
GameMetrics metrics, GameMetrics metrics,
SchoolStore store, SchoolStore store,
ModContent mods, ModContent mods,
SwarmUiSettingsStore swarmSettings,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ILogger<GameLoopService> logger) : BackgroundService ILogger<GameLoopService> logger) : BackgroundService
{ {
@@ -61,6 +62,20 @@ internal sealed class GameLoopService(
} }
} }
/// <summary>Create-time SwarmUI copy for this living school, or null on older saves.</summary>
public SwarmUiConfigFile? PortraitSettingsOf(int schoolId)
{
foreach (var worker in Volatile.Read(ref _publishedWorkers))
{
if (worker.Id == schoolId)
{
return worker.PortraitSettings;
}
}
return null;
}
/// <summary> /// <summary>
/// Menu-style read of one school's published roster and frozen catalog. Does not post to the /// Menu-style read of one school's published roster and frozen catalog. Does not post to the
/// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks. /// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks.
@@ -453,7 +468,22 @@ internal sealed class GameLoopService(
var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true); var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true);
var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true); var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed, 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); Track(worker);
worker.Start(); worker.Start();
@@ -670,7 +700,8 @@ internal sealed class GameLoopService(
createSeed: null, createSeed: null,
save.Presence, save.Presence,
save.DressRules, save.DressRules,
save.Owner); save.Owner,
save.PortraitSettings);
worker.Start(); worker.Start();
try try
@@ -727,7 +758,8 @@ internal sealed class GameLoopService(
int? createSeed = null, int? createSeed = null,
IReadOnlyList<PresenceSnapshot>? presence = null, IReadOnlyList<PresenceSnapshot>? presence = null,
SchoolDressRules? dressRules = null, SchoolDressRules? dressRules = null,
string? owner = null) => string? owner = null,
SwarmUiConfigFile? portraitSettings = null) =>
new( new(
id, id,
name, name,
@@ -744,6 +776,7 @@ internal sealed class GameLoopService(
presence, presence,
dressRules, dressRules,
owner, owner,
portraitSettings,
_options, _options,
clients, clients,
metrics, metrics,
+111 -3
View File
@@ -88,7 +88,8 @@ internal static class PersonCardReader
Capacity(person, skills, catalog), Capacity(person, skills, catalog),
person.LockerRoomId is not null person.LockerRoomId is not null
|| person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), || 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<string, float>? LiveNeeds(World world, string personId) private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -376,8 +377,8 @@ internal static class PersonCardReader
} }
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal); var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal); var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse( return new PersonFamilyResponse(
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [], inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
inParents ? Relatives(family.ChildIds, 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) : []); inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
} }
private static bool InFamily(IReadOnlyList<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IReadOnlyList<PersonRelResponse> Relatives( private static IReadOnlyList<PersonRelResponse> Relatives(
IReadOnlyList<string> ids, IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people, IReadOnlyDictionary<string, Person> people,
@@ -403,4 +417,98 @@ internal static class PersonCardReader
return rows; 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<PersonOpinionLinkResponse>();
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<string, Person> 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<PersonRelResponse> RelativesWithOpinions(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
Person person,
string except,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonRelResponse>();
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));
} }
+6 -2
View File
@@ -6,6 +6,7 @@ internal sealed class PortraitService(
SchoolStore store, SchoolStore store,
SwarmUiClient swarm, SwarmUiClient swarm,
SwarmUiSettingsStore settingsStore, SwarmUiSettingsStore settingsStore,
GameLoopService loop,
GameCommandQueue commands, GameCommandQueue commands,
ILogger<PortraitService> logger) ILogger<PortraitService> logger)
{ {
@@ -74,7 +75,7 @@ internal sealed class PortraitService(
return PortraitPromptBuildResult.UnknownSchool; return PortraitPromptBuildResult.UnknownSchool;
} }
var profile = settingsStore.Resolve(outcome.Card.Age, kind); var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved); var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
return PortraitPromptBuildResult.Succeeded( return PortraitPromptBuildResult.Succeeded(
kind, kind,
@@ -122,7 +123,7 @@ internal sealed class PortraitService(
return PortraitGenerationResult.UnknownSchool; return PortraitGenerationResult.UnknownSchool;
} }
var profile = settingsStore.Resolve(outcome.Card.Age, kind); var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra); var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
try try
@@ -155,6 +156,9 @@ internal sealed class PortraitService(
} }
} }
private SwarmUiConfigFile ConfigFor(int schoolId) =>
loop.PortraitSettingsOf(schoolId) ?? settingsStore.Current;
private async Task<PersonCardResult> LookupPersonAsync( private async Task<PersonCardResult> LookupPersonAsync(
int schoolId, int schoolId,
string personId, string personId,
+4
View File
@@ -38,6 +38,9 @@ internal sealed class SchoolSave
/// <summary>Normalized player name. Missing or blank means ownerless.</summary> /// <summary>Normalized player name. Missing or blank means ownerless.</summary>
public string? Owner { get; init; } public string? Owner { get; init; }
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
public SwarmUiConfigFile? PortraitSettings { get; init; }
} }
/// <summary>Allocates school ids that survive a process restart.</summary> /// <summary>Allocates school ids that survive a process restart.</summary>
@@ -176,6 +179,7 @@ internal sealed class SchoolStore
Presence = save.Presence, Presence = save.Presence,
DressRules = save.DressRules, DressRules = save.DressRules,
Owner = save.Owner, Owner = save.Owner,
PortraitSettings = save.PortraitSettings,
}); });
} }
catch (Exception ex) catch (Exception ex)
+13
View File
@@ -39,6 +39,7 @@ internal sealed class SchoolWorker
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence; private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly SchoolDressRules? _savedDressRules; private readonly SchoolDressRules? _savedDressRules;
private readonly string? _owner; private readonly string? _owner;
private readonly SwarmUiConfigFile? _portraitSettings;
private readonly Action<int> _onFailed; private readonly Action<int> _onFailed;
private readonly int _id; private readonly int _id;
@@ -76,6 +77,7 @@ internal sealed class SchoolWorker
IReadOnlyList<PresenceSnapshot>? savedPresence, IReadOnlyList<PresenceSnapshot>? savedPresence,
SchoolDressRules? savedDressRules, SchoolDressRules? savedDressRules,
string? owner, string? owner,
SwarmUiConfigFile? portraitSettings,
SimulationOptions options, SimulationOptions options,
ClientRegistry clients, ClientRegistry clients,
GameMetrics metrics, GameMetrics metrics,
@@ -99,6 +101,7 @@ internal sealed class SchoolWorker
_savedPresence = savedPresence; _savedPresence = savedPresence;
_savedDressRules = savedDressRules; _savedDressRules = savedDressRules;
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner; _owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
_portraitSettings = portraitSettings;
_options = options; _options = options;
_clients = clients; _clients = clients;
_metrics = metrics; _metrics = metrics;
@@ -116,6 +119,9 @@ internal sealed class SchoolWorker
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary> /// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
public SchoolState Snapshot => Volatile.Read(ref _snapshot); public SchoolState Snapshot => Volatile.Read(ref _snapshot);
/// <summary>Presets copied at create. Null on older saves — generation then uses the global template.</summary>
public SwarmUiConfigFile? PortraitSettings => _portraitSettings;
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary> /// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
@@ -983,6 +989,12 @@ internal sealed class SchoolWorker
RequireKnownApparel(catalog, roster, applicants); RequireKnownApparel(catalog, roster, applicants);
} }
if (OpinionGenerator.NeedsFamilyOpinions(roster))
{
roster = OpinionGenerator.SeedFamily(catalog, roster);
generated = true;
}
roster = LockerAssigner.Apply(catalog, map, roster); roster = LockerAssigner.Apply(catalog, map, roster);
if (!RosterFit.Matches(roster, demand)) if (!RosterFit.Matches(roster, demand))
@@ -1069,6 +1081,7 @@ internal sealed class SchoolWorker
Presence = school.CapturePresence(), Presence = school.CapturePresence(),
DressRules = school.DressRules, DressRules = school.DressRules,
Owner = _owner, Owner = _owner,
PortraitSettings = _portraitSettings,
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -233,6 +233,33 @@ internal sealed class SwarmUiConfigFile
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()], Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }], AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
}; };
public static SwarmUiConfigFile Clone(SwarmUiConfigFile source)
{
var json = JsonSerializer.Serialize(source, CloneJson);
var copy = JsonSerializer.Deserialize<SwarmUiConfigFile>(json, CloneJson) ?? CreateDefault();
copy.Model = null;
copy.Steps = null;
copy.CfgScale = null;
copy.ClipSkip = null;
copy.Sampler = null;
copy.Scheduler = null;
copy.Seed = null;
copy.Positive = null;
copy.Negative = null;
copy.Avatar = null;
copy.Custom = null;
copy.FullBody = null;
copy.NormalizeAfterLoad();
return copy;
}
private static readonly JsonSerializerOptions CloneJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
} }
internal sealed class SwarmUiPresetDefinition internal sealed class SwarmUiPresetDefinition
@@ -46,4 +46,20 @@
"heavyOuterAboveC": 15, "heavyOuterAboveC": 15,
"apparelGoalWeight": 6, "apparelGoalWeight": 6,
"changeClothesMinutes": 5, "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" },
],
} }
@@ -180,6 +180,13 @@
"ApparelConditionWorn": "worn", "ApparelConditionWorn": "worn",
"ApparelConditionTorn": "torn", "ApparelConditionTorn": "torn",
"ApparelConditionRags": "in rags", "ApparelConditionRags": "in rags",
"OpinionCloseFriend": "close friends",
"OpinionFriend": "friends",
"OpinionPleasant": "pleasant",
"OpinionAcquaintance": "acquaintances",
"OpinionStrained": "strained",
"OpinionDislike": "dislike",
"OpinionEnemy": "enemies",
"ActionStarted": "started: {0}", "ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}", "ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}", "ApparelReplaced": "got a new {0}",
@@ -180,6 +180,13 @@
"ApparelConditionWorn": "поношенная", "ApparelConditionWorn": "поношенная",
"ApparelConditionTorn": "порванная", "ApparelConditionTorn": "порванная",
"ApparelConditionRags": "висит лохмотьями", "ApparelConditionRags": "висит лохмотьями",
"OpinionCloseFriend": "близкие друзья",
"OpinionFriend": "друзья",
"OpinionPleasant": "приятели",
"OpinionAcquaintance": "знакомые",
"OpinionStrained": "натянуто",
"OpinionDislike": "неприязнь",
"OpinionEnemy": "враги",
"ActionStarted": "начал: {0}", "ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}", "ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}", "ApparelReplaced": "получил новую {0}",
+2 -1
View File
@@ -53,9 +53,10 @@ internal static class ApparelWear
} }
var morningChanged = MorningDress.Apply(school); var morningChanged = MorningDress.Apply(school);
var opinionsChanged = MorningOpinions.Apply(school);
var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays) var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)
&& ReplaceRags(school); && ReplaceRags(school);
return bandCrossed | morningChanged | ragsReplaced; return bandCrossed | morningChanged | opinionsChanged | ragsReplaced;
} }
internal static bool CrossedDayStart(DateTime before, DateTime after) internal static bool CrossedDayStart(DateTime before, DateTime after)
+66
View File
@@ -0,0 +1,66 @@
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>Drifts non-zero opinions toward family basis or zero at six on work mornings.</summary>
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<string, Person> 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;
}
}
+51 -2
View File
@@ -177,6 +177,36 @@ public class PeopleApiTests(AppHostFixture fixture)
Assert.Contains(motherCard.Family.Children, child => child.Id == card.Id); 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);
}
/// <summary> /// <summary>
/// Phases 7 and 9 together: the roster survives a restart, and what comes back is the /// 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 /// composition *after* the first-September intake. Generating from the seed again would
@@ -320,7 +350,8 @@ public class PeopleApiTests(AppHostFixture fixture)
IReadOnlyList<LabeledStatResponse> Skills, IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits, IReadOnlyList<DefLabelResponse> Traits,
IReadOnlyList<NeedStatResponse> Needs, IReadOnlyList<NeedStatResponse> Needs,
PersonFamilyResponse Family); PersonFamilyResponse Family,
PersonConnectionsResponse? Connections);
private sealed record LabeledStatResponse(string Id, string Label, string Value); private sealed record LabeledStatResponse(string Id, string Label, string Value);
@@ -332,7 +363,25 @@ public class PeopleApiTests(AppHostFixture fixture)
IReadOnlyList<PersonRelResponse> Siblings, IReadOnlyList<PersonRelResponse> Siblings,
IReadOnlyList<PersonRelResponse> Partners); IReadOnlyList<PersonRelResponse> 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<PersonOpinionLinkResponse> Friends,
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
IReadOnlyList<PersonOpinionLinkResponse> Others);
private sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People); private sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
namespace HSchool.AppHost.Tests; namespace HSchool.AppHost.Tests;
@@ -104,6 +105,40 @@ public class PortraitApiTests(AppHostFixture fixture)
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
} }
[Fact]
public async Task CreateSchool_CopiesPortraitSettingsIntoTheSave()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пресеты школы", Start);
var directory = await SavesDirectoryAsync(client);
var json = await File.ReadAllTextAsync(
Path.Combine(directory, $"{school.Id}.json"),
TestContext.Current.CancellationToken);
using var document = JsonDocument.Parse(json);
Assert.True(document.RootElement.TryGetProperty("portraitSettings", out var presets));
Assert.True(presets.TryGetProperty("presets", out var list));
Assert.True(list.GetArrayLength() > 0);
}
[Fact]
public async Task CreateSchool_RejectsEmptyPortraitPresets()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Плохие пресеты",
startDate = Start,
portraitSettings = new { activePresetId = "missing", presets = Array.Empty<object>(), ageRules = Array.Empty<object>() },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-portrait-settings", await SchoolApiTests.ProblemCodeAsync(response));
}
[Fact] [Fact]
public async Task DeleteSchool_RemovesPortraitDirectory() public async Task DeleteSchool_RemovesPortraitDirectory()
{ {
+11 -21
View File
@@ -592,26 +592,7 @@ public class SchoolApiTests(AppHostFixture fixture)
internal static async Task ResetAsync(HttpClient client) internal static async Task ResetAsync(HttpClient client)
{ {
await LoginAsync(client); await WipeAllSavesAsync(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();
}
} }
internal static async Task WipeAllSavesAsync(HttpClient client) internal static async Task WipeAllSavesAsync(HttpClient client)
@@ -731,7 +712,16 @@ public class SchoolApiTests(AppHostFixture fixture)
return payload.Path; 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<SwarmUiPresetSave>? Presets);
private sealed record SwarmUiPresetSave(string? Id, string? Model);
internal static readonly object SimpleCustomMap = new internal static readonly object SimpleCustomMap = new
{ {
@@ -71,6 +71,9 @@ public class SchoolOwnerApiTests(AppHostFixture fixture)
{ {
client.Dispose(); client.Dispose();
} }
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
} }
} }
@@ -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);
}
}
@@ -40,4 +40,18 @@ public class SwarmUiSettingsStoreTests
Assert.Equal("legacy.safetensors", config.Presets[0].Model); Assert.Equal("legacy.safetensors", config.Presets[0].Model);
Assert.Equal(12, config.Presets[0].Steps); Assert.Equal(12, config.Presets[0].Steps);
} }
[Fact]
public void Clone_IsIndependentOfTheSource()
{
var source = SwarmUiConfigFile.CreateDefault();
source.Presets[0].Model = "mutated.safetensors";
var copy = SwarmUiConfigFile.Clone(source);
copy.Presets[0].Model = "other.safetensors";
Assert.Equal("mutated.safetensors", source.Presets[0].Model);
Assert.Equal("other.safetensors", copy.Presets[0].Model);
Assert.Equal(source.ActivePresetId, copy.ActivePresetId);
}
} }
@@ -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<ContentDocument>();
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);
}
}