Complete phase 47 romance pack on generic orientation and affinity hooks.
Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
This commit is contained in:
@@ -237,6 +237,13 @@ mods/
|
|||||||
localizations/
|
localizations/
|
||||||
ru.jsonc
|
ru.jsonc
|
||||||
en.jsonc
|
en.jsonc
|
||||||
|
romance/ # контентный пак: ориентация, симпатия, пары 18+, темы
|
||||||
|
defs/orientations/
|
||||||
|
defs/affinity/
|
||||||
|
defs/traits/
|
||||||
|
defs/topics/
|
||||||
|
patches/
|
||||||
|
localizations/
|
||||||
```
|
```
|
||||||
|
|
||||||
Порядок загрузки: сначала `core`, потом выбранные моды в том порядке, в каком они стоят в
|
Порядок загрузки: сначала `core`, потом выбранные моды в том порядке, в каком они стоят в
|
||||||
|
|||||||
@@ -13,27 +13,27 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные,
|
- [x] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные,
|
||||||
черты влюбчивый/ревнивый полями, патч тем и при необходимости `Chat`
|
черты влюбчивый/ревнивый полями, патч тем и при необходимости `Chat`
|
||||||
- [ ] Без пака в create ориентации и романтических состояний нет; ванильный каталог без
|
- [x] Без пака в create ориентации и романтических состояний нет; ванильный каталог без
|
||||||
романтических тем
|
романтических тем
|
||||||
- [ ] Симпатия учеников: ±2 года или соседняя параллель
|
- [x] Симпатия учеников: ±2 года или соседняя параллель
|
||||||
- [ ] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда;
|
- [x] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда;
|
||||||
осадить — разговор; пары сотрудник↔ученик нет
|
осадить — разговор; пары сотрудник↔ученик нет
|
||||||
- [ ] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня
|
- [x] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня
|
||||||
- [ ] Сотрудники между собой — симпатия и пара можно
|
- [x] Сотрудники между собой — симпатия и пара можно
|
||||||
- [ ] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык
|
- [x] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык
|
||||||
- [ ] Тесты хоста: школа с паком и без; запрет учительской взаимности
|
- [x] Тесты хоста: школа с паком и без; запрет учительской взаимности
|
||||||
- [ ] `example` не трогать
|
- [x] `example` не трогать
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] `GET /api/catalog` без `romance` не отдаёт романтические темы
|
- [x] `GET /api/catalog` без `romance` не отдаёт романтические темы
|
||||||
- [ ] Create с паком пишет ориентацию; без пака поля нет
|
- [x] Create с паком пишет ориентацию; без пака поля нет
|
||||||
- [ ] Двое 16-летних не получают статус пары при любом мнении
|
- [x] Двое 16-летних не получают статус пары при любом мнении
|
||||||
- [ ] Учитель не получает романтическое состояние на ученика
|
- [x] Учитель не получает романтическое состояние на ученика
|
||||||
- [ ] Первоклассник не получает симпатию к одиннадцатому (возраст)
|
- [x] Первоклассник не получает симпатию к одиннадцатому (возраст)
|
||||||
- [ ] Школа с `example` и без `romance` ведёт себя как ваниль в части тем
|
- [x] Школа с `example` и без `romance` ведёт себя как ваниль в части тем
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -119,7 +119,9 @@ still a pack: the id stands in for the name, `version` is empty, `requires` is e
|
|||||||
```
|
```
|
||||||
|
|
||||||
The repo also ships `example` next to `core` — a sample pack, not required. It appears in this
|
The repo also ships `example` next to `core` — a sample pack, not required. It appears in this
|
||||||
list with `required: false` and `requires: ["core"]`. Vanilla create omits it.
|
list with `required: false` and `requires: ["core"]`. Vanilla create omits it. `romance` is a
|
||||||
|
content pack on the same list; a school created without it has no orientations and no romantic
|
||||||
|
topics.
|
||||||
|
|
||||||
### `GET /api/catalog?lang=ru|en&mods=addon1,addon2`
|
### `GET /api/catalog?lang=ru|en&mods=addon1,addon2`
|
||||||
|
|
||||||
@@ -159,6 +161,11 @@ academic year; a range whose start is after its end wraps across 1 January.
|
|||||||
The assignment form in a later phase reads subjects; the timetable grid reads the day frame.
|
The assignment form in a later phase reads subjects; the timetable grid reads the day frame.
|
||||||
The create editor does not.
|
The create editor does not.
|
||||||
|
|
||||||
|
`topics` is the list of placeable conversation subjects (`defName`, label, `tags`). Vanilla
|
||||||
|
ships study/games/food/family/sport/gossip/rude/appearance. A content pack may add more — the
|
||||||
|
`romance` pack adds crush/couple/tease topics and a `romance` tag; they are absent from a
|
||||||
|
catalog requested without that pack. HTTP JSON is additive — no protocol version bump.
|
||||||
|
|
||||||
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
||||||
Russian.
|
Russian.
|
||||||
|
|
||||||
@@ -328,7 +335,10 @@ not happen on this request.
|
|||||||
|
|
||||||
`connections` is the **Связи** tab: family links with `opinion` / `opinionLabel` from catalog bands,
|
`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).
|
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.
|
When a school was created with a pack that ships orientations, the card also carries
|
||||||
|
`orientation` (`defName` + label) and `connections.crushes` / `admirers` / `pair`. Without that
|
||||||
|
pack those fields are null or empty — the client hides the sympathy column. The client does not
|
||||||
|
compute thresholds. There is no school-wide opinions endpoint.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ const ru = {
|
|||||||
peopleConnectionsEnemies: 'Враги',
|
peopleConnectionsEnemies: 'Враги',
|
||||||
peopleConnectionsSearch: 'Поиск по связям',
|
peopleConnectionsSearch: 'Поиск по связям',
|
||||||
peopleConnectionsEmpty: 'Нет других связей.',
|
peopleConnectionsEmpty: 'Нет других связей.',
|
||||||
|
peopleConnectionsCrushes: 'Симпатии',
|
||||||
|
peopleConnectionsAdmirers: 'Нравятся этому человеку',
|
||||||
|
peopleConnectionsPair: 'Пара',
|
||||||
|
peopleOrientation: 'Ориентация',
|
||||||
peopleOpinionValue: '{label} ({value})',
|
peopleOpinionValue: '{label} ({value})',
|
||||||
peopleAtHome: 'дома',
|
peopleAtHome: 'дома',
|
||||||
peopleApparelFit: 'Уместность: {value}',
|
peopleApparelFit: 'Уместность: {value}',
|
||||||
@@ -563,6 +567,10 @@ const en: Messages = {
|
|||||||
peopleConnectionsEnemies: 'Enemies',
|
peopleConnectionsEnemies: 'Enemies',
|
||||||
peopleConnectionsSearch: 'Search connections',
|
peopleConnectionsSearch: 'Search connections',
|
||||||
peopleConnectionsEmpty: 'No other connections.',
|
peopleConnectionsEmpty: 'No other connections.',
|
||||||
|
peopleConnectionsCrushes: 'Crushes',
|
||||||
|
peopleConnectionsAdmirers: 'Admirers',
|
||||||
|
peopleConnectionsPair: 'Couple',
|
||||||
|
peopleOrientation: 'Orientation',
|
||||||
peopleOpinionValue: '{label} ({value})',
|
peopleOpinionValue: '{label} ({value})',
|
||||||
peopleAtHome: 'home',
|
peopleAtHome: 'home',
|
||||||
peopleApparelFit: 'Fit: {value}',
|
peopleApparelFit: 'Fit: {value}',
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ export interface MapLayout {
|
|||||||
links: { a: string; b: string }[];
|
links: { a: string; b: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopicInfo {
|
||||||
|
readonly defName: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogResponse {
|
export interface CatalogResponse {
|
||||||
readonly territories: readonly DefInfo[];
|
readonly territories: readonly DefInfo[];
|
||||||
readonly buildings: readonly DefInfo[];
|
readonly buildings: readonly DefInfo[];
|
||||||
@@ -192,6 +198,8 @@ export interface CatalogResponse {
|
|||||||
readonly subjects: readonly SubjectInfo[];
|
readonly subjects: readonly SubjectInfo[];
|
||||||
readonly dayFrame: DayFrameInfo | null;
|
readonly dayFrame: DayFrameInfo | null;
|
||||||
readonly holidays: readonly HolidayInfo[];
|
readonly holidays: readonly HolidayInfo[];
|
||||||
|
/** Conversation subjects. A content pack may add more; vanilla has no romance tags. */
|
||||||
|
readonly topics?: readonly TopicInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
|
export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
|
||||||
@@ -301,6 +309,9 @@ export interface PersonConnections {
|
|||||||
readonly friends: readonly PersonOpinionLink[];
|
readonly friends: readonly PersonOpinionLink[];
|
||||||
readonly enemies: readonly PersonOpinionLink[];
|
readonly enemies: readonly PersonOpinionLink[];
|
||||||
readonly others: readonly PersonOpinionLink[];
|
readonly others: readonly PersonOpinionLink[];
|
||||||
|
readonly crushes?: readonly PersonOpinionLink[];
|
||||||
|
readonly admirers?: readonly PersonOpinionLink[];
|
||||||
|
readonly pair?: PersonRel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonCard {
|
export interface PersonCard {
|
||||||
@@ -341,6 +352,7 @@ export interface PersonCard {
|
|||||||
readonly hasFullBody: boolean;
|
readonly hasFullBody: boolean;
|
||||||
readonly customPortraitPrompt: string | null;
|
readonly customPortraitPrompt: string | null;
|
||||||
readonly connections: PersonConnections | null;
|
readonly connections: PersonConnections | null;
|
||||||
|
readonly orientation?: DefLabel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WornItem {
|
export interface WornItem {
|
||||||
|
|||||||
@@ -291,6 +291,35 @@ describe('renderPersonCard', () => {
|
|||||||
expect(root.querySelector('.people__log-tools')).toBeNull();
|
expect(root.querySelector('.people__log-tools')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides crush columns without orientation and shows them with a pack', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderPersonCard(root, card(), () => {}, { tab: 'connections' });
|
||||||
|
expect(root.textContent).not.toContain('Симпатии');
|
||||||
|
|
||||||
|
root.replaceChildren();
|
||||||
|
renderPersonCard(
|
||||||
|
root,
|
||||||
|
card({
|
||||||
|
orientation: { defName: 'Heterosexual', label: 'гетеро' },
|
||||||
|
connections: {
|
||||||
|
family: { parents: [], children: [], siblings: [], partners: [] },
|
||||||
|
friends: [],
|
||||||
|
enemies: [],
|
||||||
|
others: [],
|
||||||
|
crushes: [{ id: 't1', fullName: 'Учитель', female: false, opinion: 60, opinionLabel: 'друзья' }],
|
||||||
|
admirers: [],
|
||||||
|
pair: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
{ tab: 'connections' },
|
||||||
|
);
|
||||||
|
expect(root.textContent).toContain('гетеро');
|
||||||
|
expect(root.textContent).toContain('Симпатии');
|
||||||
|
expect(root.textContent).toContain('Учитель');
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -266,6 +266,12 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (card.orientation != null) {
|
||||||
|
const orientation = section(t('peopleOrientation'));
|
||||||
|
orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label }));
|
||||||
|
parent.append(orientation);
|
||||||
|
}
|
||||||
|
|
||||||
const family = section(t('peopleFamily'));
|
const family = section(t('peopleFamily'));
|
||||||
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
|
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
|
||||||
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
|
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
|
||||||
@@ -275,6 +281,17 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id:
|
|||||||
parent.append(family);
|
parent.append(family);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (connections.pair != null) {
|
||||||
|
appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative);
|
||||||
|
}
|
||||||
|
|
||||||
|
const crushes = connections.crushes ?? [];
|
||||||
|
const admirers = connections.admirers ?? [];
|
||||||
|
if (card.orientation != null || crushes.length > 0 || admirers.length > 0) {
|
||||||
|
appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative);
|
||||||
|
appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative);
|
||||||
|
}
|
||||||
|
|
||||||
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
|
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
|
||||||
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
|
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,8 @@ public sealed class CatalogLoader
|
|||||||
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
|
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
|
||||||
var colors = new Dictionary<string, ColorDef>(StringComparer.Ordinal);
|
var colors = new Dictionary<string, ColorDef>(StringComparer.Ordinal);
|
||||||
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
|
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
|
||||||
|
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
|
||||||
|
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var (key, json) in resolved)
|
foreach (var (key, json) in resolved)
|
||||||
{
|
{
|
||||||
@@ -384,6 +386,12 @@ public sealed class CatalogLoader
|
|||||||
case DefKind.Topic:
|
case DefKind.Topic:
|
||||||
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
|
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
|
||||||
break;
|
break;
|
||||||
|
case DefKind.Orientation:
|
||||||
|
orientations[key.Name] = Jsonc.Deserialize<OrientationDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.AffinityRules:
|
||||||
|
affinity[key.Name] = Jsonc.Deserialize<AffinityRulesDef>(json);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +418,8 @@ public sealed class CatalogLoader
|
|||||||
behavior,
|
behavior,
|
||||||
colors,
|
colors,
|
||||||
topics,
|
topics,
|
||||||
|
orientations,
|
||||||
|
affinity,
|
||||||
ru,
|
ru,
|
||||||
en);
|
en);
|
||||||
}
|
}
|
||||||
@@ -558,7 +568,10 @@ public sealed class CatalogLoader
|
|||||||
.Concat(Enumerate(catalog.DayFrames.Values))
|
.Concat(Enumerate(catalog.DayFrames.Values))
|
||||||
.Concat(Enumerate(catalog.Holidays.Values))
|
.Concat(Enumerate(catalog.Holidays.Values))
|
||||||
.Concat(Enumerate(catalog.Behavior.Values))
|
.Concat(Enumerate(catalog.Behavior.Values))
|
||||||
.Concat(Enumerate(catalog.Colors.Values));
|
.Concat(Enumerate(catalog.Colors.Values))
|
||||||
|
.Concat(Enumerate(catalog.Topics.Values))
|
||||||
|
.Concat(Enumerate(catalog.Orientations.Values))
|
||||||
|
.Concat(Enumerate(catalog.Affinity.Values));
|
||||||
|
|
||||||
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
|
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public sealed class DefCatalog
|
|||||||
IReadOnlyDictionary<string, BehaviorDef> behavior,
|
IReadOnlyDictionary<string, BehaviorDef> behavior,
|
||||||
IReadOnlyDictionary<string, ColorDef> colors,
|
IReadOnlyDictionary<string, ColorDef> colors,
|
||||||
IReadOnlyDictionary<string, TopicDef> topics,
|
IReadOnlyDictionary<string, TopicDef> topics,
|
||||||
|
IReadOnlyDictionary<string, OrientationDef> orientations,
|
||||||
|
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
|
||||||
IReadOnlyDictionary<string, string> ru,
|
IReadOnlyDictionary<string, string> ru,
|
||||||
IReadOnlyDictionary<string, string> en)
|
IReadOnlyDictionary<string, string> en)
|
||||||
{
|
{
|
||||||
@@ -54,6 +56,8 @@ public sealed class DefCatalog
|
|||||||
Behavior = behavior;
|
Behavior = behavior;
|
||||||
Colors = colors;
|
Colors = colors;
|
||||||
Topics = topics;
|
Topics = topics;
|
||||||
|
Orientations = orientations;
|
||||||
|
Affinity = affinity;
|
||||||
_ru = ru;
|
_ru = ru;
|
||||||
_en = en;
|
_en = en;
|
||||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
||||||
@@ -113,6 +117,10 @@ public sealed class DefCatalog
|
|||||||
|
|
||||||
public IReadOnlyDictionary<string, TopicDef> Topics { get; }
|
public IReadOnlyDictionary<string, TopicDef> Topics { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, OrientationDef> Orientations { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, AffinityRulesDef> Affinity { get; }
|
||||||
|
|
||||||
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
|
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
|
||||||
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
@@ -122,6 +130,9 @@ public sealed class DefCatalog
|
|||||||
/// <summary>The one concrete behaviour ruleset, or null when a pack has not defined it.</summary>
|
/// <summary>The one concrete behaviour ruleset, or null when a pack has not defined it.</summary>
|
||||||
public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract);
|
public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
|
/// <summary>The one concrete affinity ruleset, or null when no pack defined it (vanilla).</summary>
|
||||||
|
public AffinityRulesDef? AffinityRules => Affinity.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
private readonly IReadOnlyDictionary<string, string> _ru;
|
private readonly IReadOnlyDictionary<string, string> _ru;
|
||||||
private readonly IReadOnlyDictionary<string, string> _en;
|
private readonly IReadOnlyDictionary<string, string> _en;
|
||||||
|
|
||||||
@@ -150,6 +161,8 @@ public sealed class DefCatalog
|
|||||||
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
|
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
|
||||||
DefKind.Color => Colors.GetValueOrDefault(defName),
|
DefKind.Color => Colors.GetValueOrDefault(defName),
|
||||||
DefKind.Topic => Topics.GetValueOrDefault(defName),
|
DefKind.Topic => Topics.GetValueOrDefault(defName),
|
||||||
|
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
|
||||||
|
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,6 +244,8 @@ public sealed class DefCatalog
|
|||||||
BehaviorDef => DefKind.Behavior,
|
BehaviorDef => DefKind.Behavior,
|
||||||
ColorDef => DefKind.Color,
|
ColorDef => DefKind.Color,
|
||||||
TopicDef => DefKind.Topic,
|
TopicDef => DefKind.Topic,
|
||||||
|
OrientationDef => DefKind.Orientation,
|
||||||
|
AffinityRulesDef => DefKind.AffinityRules,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public enum DefKind
|
|||||||
Behavior,
|
Behavior,
|
||||||
Color,
|
Color,
|
||||||
Topic,
|
Topic,
|
||||||
|
Orientation,
|
||||||
|
AffinityRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ internal static class PackPaths
|
|||||||
case "topics":
|
case "topics":
|
||||||
kind = DefKind.Topic;
|
kind = DefKind.Topic;
|
||||||
return true;
|
return true;
|
||||||
|
case "orientations":
|
||||||
|
kind = DefKind.Orientation;
|
||||||
|
return true;
|
||||||
|
case "affinity":
|
||||||
|
kind = DefKind.AffinityRules;
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
kind = default;
|
kind = default;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ internal static class PeopleDefValidator
|
|||||||
ValidateTopic(topic, catalog);
|
ValidateTopic(topic, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var orientation in catalog.Orientations.Values)
|
||||||
|
{
|
||||||
|
ValidateOrientation(orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var affinity in catalog.Affinity.Values)
|
||||||
|
{
|
||||||
|
ValidateAffinityRules(affinity);
|
||||||
|
}
|
||||||
|
|
||||||
RequireTalkTopics(catalog);
|
RequireTalkTopics(catalog);
|
||||||
|
|
||||||
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
||||||
@@ -87,6 +97,11 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException("A catalog may only have one concrete BehaviorDef.");
|
throw new ContentLoadException("A catalog may only have one concrete BehaviorDef.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (catalog.Affinity.Values.Count(def => !def.Abstract) > 1)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException("A catalog may only have one concrete AffinityRulesDef.");
|
||||||
|
}
|
||||||
|
|
||||||
RequireBuildInputs(catalog);
|
RequireBuildInputs(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +622,60 @@ internal static class PeopleDefValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateOrientation(OrientationDef orientation)
|
||||||
|
{
|
||||||
|
if (orientation.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation.Weight < 1)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"OrientationDef '{orientation.DefName}' weight must be at least 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orientation.SameGender && !orientation.OppositeGender)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"OrientationDef '{orientation.DefName}' must allow same gender, opposite gender, or both.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAffinityRules(AffinityRulesDef rules)
|
||||||
|
{
|
||||||
|
if (rules.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.SympathyThreshold < OpinionLabelsMin || rules.PairThreshold < OpinionLabelsMin
|
||||||
|
|| rules.PairBreakThreshold < OpinionLabelsMin)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' opinion thresholds are out of range.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.PairMinAge < 0)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' pairMinAge cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.StudentAgeDeltaYears < 0)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' studentAgeDeltaYears cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in rules.RolePairs)
|
||||||
|
{
|
||||||
|
if (!PersonRoles.IsKnown(pair.From) || !PersonRoles.IsKnown(pair.To))
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"AffinityRulesDef '{rules.DefName}' role pair '{pair.From}'→'{pair.To}' uses an unknown role.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int OpinionLabelsMin = -100;
|
||||||
|
|
||||||
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
|
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
|
||||||
private static void RequireTalkTopics(DefCatalog catalog)
|
private static void RequireTalkTopics(DefCatalog catalog)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -210,6 +210,83 @@ public sealed class TraitDef : Def
|
|||||||
|
|
||||||
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
|
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
|
||||||
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
|
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without
|
||||||
|
/// affinity rules ignores the field.
|
||||||
|
/// </summary>
|
||||||
|
public int AffinityThresholdOffset { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still
|
||||||
|
/// higher (jealous). Ignored without affinity rules.
|
||||||
|
/// </summary>
|
||||||
|
public int AffinityBreakOffset { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who this person is drawn to. Present only when a pack ships orientations; vanilla catalogs
|
||||||
|
/// have none, and the generator then writes no orientation field.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrientationDef : Def
|
||||||
|
{
|
||||||
|
public int Weight { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Attracted to people of the same gender.</summary>
|
||||||
|
public bool SameGender { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Attracted to people of the opposite gender.</summary>
|
||||||
|
public bool OppositeGender { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One directed role pairing the affinity overlay may use. Empty <see cref="AffinityRulesDef.RolePairs"/>
|
||||||
|
/// means the overlay never fires, even if orientations exist.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AffinityRolePair
|
||||||
|
{
|
||||||
|
/// <summary><see cref="PersonRoles"/> id of the person who holds the feeling.</summary>
|
||||||
|
public required string From { get; init; }
|
||||||
|
|
||||||
|
/// <summary><see cref="PersonRoles"/> id of the person it is about.</summary>
|
||||||
|
public required string To { get; init; }
|
||||||
|
|
||||||
|
public bool Sympathy { get; init; }
|
||||||
|
|
||||||
|
public bool Pair { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional singleton, like <see cref="BehaviorDef"/>. Vanilla core does not ship one; a content
|
||||||
|
/// pack that wants crushes and pairs adds the numbers. Simulation reads the def if present and
|
||||||
|
/// no-ops when it is missing — packs are not named in code.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AffinityRulesDef : Def
|
||||||
|
{
|
||||||
|
/// <summary>Opinion at or above this (plus trait offset) becomes a one-way crush.</summary>
|
||||||
|
public int SympathyThreshold { get; init; } = 50;
|
||||||
|
|
||||||
|
/// <summary>Mutual crushes at or above this may become a pair, if the role pair allows it.</summary>
|
||||||
|
public int PairThreshold { get; init; } = 70;
|
||||||
|
|
||||||
|
/// <summary>A pair ends when either opinion falls below this (plus jealous offset).</summary>
|
||||||
|
public int PairBreakThreshold { get; init; } = 20;
|
||||||
|
|
||||||
|
/// <summary>Both people must be at least this old to pair. Sympathy has its own age filter.</summary>
|
||||||
|
public int PairMinAge { get; init; } = 18;
|
||||||
|
|
||||||
|
/// <summary>Student–student sympathy: age gap no larger than this, or adjacent year.</summary>
|
||||||
|
public int StudentAgeDeltaYears { get; init; } = 2;
|
||||||
|
|
||||||
|
public bool StudentAdjacentYear { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Family members never get a crush or pair. Kinship is not this overlay.</summary>
|
||||||
|
public bool ExcludeFamily { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Opinion the rebuffed person loses when a one-way crush is turned down in talk.</summary>
|
||||||
|
public int RebuffOpinionShift { get; init; } = -8;
|
||||||
|
|
||||||
|
public IReadOnlyList<AffinityRolePair> RolePairs { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>
|
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>One overlay change the simulation can log. Type is a person-log id, not a pack name.</summary>
|
||||||
|
public sealed record AffinityEvent(string PersonId, string Type, string? OtherId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crush and pair overlay driven by <see cref="AffinityRulesDef"/>. No-ops when the catalog has
|
||||||
|
/// no rules or no orientations — packs are not named here.
|
||||||
|
/// </summary>
|
||||||
|
public static class Affinity
|
||||||
|
{
|
||||||
|
public const string Crush = "affinity-crush";
|
||||||
|
public const string Pair = "affinity-pair";
|
||||||
|
public const string PairBreak = "affinity-pair-break";
|
||||||
|
public const string Tease = "affinity-tease";
|
||||||
|
public const string Rebuff = "affinity-rebuff";
|
||||||
|
|
||||||
|
public static IReadOnlyList<AffinityEvent> Refresh(DefCatalog catalog, Roster roster, DateTime asOf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
|
|
||||||
|
var rules = catalog.AffinityRules;
|
||||||
|
if (rules is null || catalog.Orientations.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||||
|
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||||
|
var events = new List<AffinityEvent>();
|
||||||
|
var ordered = roster.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
|
||||||
|
|
||||||
|
foreach (var person in ordered)
|
||||||
|
{
|
||||||
|
DropInvalid(catalog, rules, roster, people, years, person, asOf, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var person in ordered)
|
||||||
|
{
|
||||||
|
GainCrushes(catalog, rules, roster, people, years, person, asOf, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var person in ordered)
|
||||||
|
{
|
||||||
|
TryFormPair(catalog, rules, roster, people, years, person, asOf, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<AffinityEvent> RebuffTalk(
|
||||||
|
DefCatalog catalog,
|
||||||
|
Roster roster,
|
||||||
|
IReadOnlyList<string> talkedIds,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
|
ArgumentNullException.ThrowIfNull(talkedIds);
|
||||||
|
|
||||||
|
var rules = catalog.AffinityRules;
|
||||||
|
if (rules is null || catalog.Orientations.Count == 0 || talkedIds.Count < 2)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||||
|
var present = talkedIds.Where(people.ContainsKey).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||||
|
var events = new List<AffinityEvent>();
|
||||||
|
foreach (var fromId in present)
|
||||||
|
{
|
||||||
|
var from = people[fromId];
|
||||||
|
if (from.Bonds is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var crushId in from.Bonds.Crushes.ToArray())
|
||||||
|
{
|
||||||
|
if (!people.TryGetValue(crushId, out var to) || !present.Contains(crushId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!from.IsStudent || !to.IsStaff)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RoleAllows(rules, to, from, pair: false))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutable = OpinionGenerator.EnsureMutableOpinions(from);
|
||||||
|
var current = OpinionStore.Get(mutable, to.Id) ?? 0;
|
||||||
|
OpinionStore.Set(mutable, to.Id, current + rules.RebuffOpinionShift);
|
||||||
|
events.Add(new AffinityEvent(from.Id, Rebuff, to.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = asOf;
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanHaveSympathy(
|
||||||
|
DefCatalog catalog,
|
||||||
|
Roster roster,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
var rules = catalog.AffinityRules;
|
||||||
|
if (rules is null || from.Id.Equals(to.Id, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||||
|
return CanSympathy(catalog, rules, roster, years, from, to, asOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanFormPair(
|
||||||
|
DefCatalog catalog,
|
||||||
|
Roster roster,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
var rules = catalog.AffinityRules;
|
||||||
|
if (rules is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||||
|
return CanPair(catalog, rules, roster, years, from, to, asOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DropInvalid(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
Dictionary<string, Person> people,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person person,
|
||||||
|
DateTime asOf,
|
||||||
|
List<AffinityEvent> events)
|
||||||
|
{
|
||||||
|
var bonds = EnsureBonds(person);
|
||||||
|
if (bonds is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep = new List<string>();
|
||||||
|
foreach (var crushId in bonds.Crushes)
|
||||||
|
{
|
||||||
|
if (people.TryGetValue(crushId, out var target)
|
||||||
|
&& CanSympathy(catalog, rules, roster, years, person, target, asOf))
|
||||||
|
{
|
||||||
|
keep.Add(crushId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keep.Count != bonds.Crushes.Count)
|
||||||
|
{
|
||||||
|
bonds.Crushes.Clear();
|
||||||
|
bonds.Crushes.AddRange(keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bonds.PartnerId is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!people.TryGetValue(bonds.PartnerId, out var partner)
|
||||||
|
|| !CanRemainPair(catalog, rules, roster, years, person, partner, asOf))
|
||||||
|
{
|
||||||
|
var otherId = bonds.PartnerId;
|
||||||
|
BreakPair(person, partner, asOf);
|
||||||
|
events.Add(new AffinityEvent(person.Id, PairBreak, otherId));
|
||||||
|
if (partner is not null)
|
||||||
|
{
|
||||||
|
events.Add(new AffinityEvent(partner.Id, PairBreak, person.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GainCrushes(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
Dictionary<string, Person> people,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person person,
|
||||||
|
DateTime asOf,
|
||||||
|
List<AffinityEvent> events)
|
||||||
|
{
|
||||||
|
var bonds = EnsureBonds(person);
|
||||||
|
if (bonds is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var threshold = rules.SympathyThreshold + TraitOffset(catalog, person, breakOffset: false);
|
||||||
|
foreach (var (targetId, opinion) in person.Opinions)
|
||||||
|
{
|
||||||
|
if (opinion < threshold || !people.TryGetValue(targetId, out var target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanSympathy(catalog, rules, roster, years, person, target, asOf))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bonds.Crushes.Contains(targetId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bonds.Crushes.Add(targetId);
|
||||||
|
events.Add(new AffinityEvent(person.Id, Crush, targetId));
|
||||||
|
if (person.IsStudent)
|
||||||
|
{
|
||||||
|
events.Add(new AffinityEvent(person.Id, Tease, targetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryFormPair(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
Dictionary<string, Person> people,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person person,
|
||||||
|
DateTime asOf,
|
||||||
|
List<AffinityEvent> events)
|
||||||
|
{
|
||||||
|
var bonds = person.Bonds;
|
||||||
|
if (bonds is null || bonds.PartnerId is not null || !CooldownElapsed(bonds, asOf))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var threshold = rules.PairThreshold;
|
||||||
|
foreach (var crushId in bonds.Crushes.OrderBy(id => id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!people.TryGetValue(crushId, out var other) || other.Bonds is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other.Bonds.PartnerId is not null || !CooldownElapsed(other.Bonds, asOf))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromView = OpinionStore.Get(person, other.Id) ?? 0;
|
||||||
|
var toView = OpinionStore.Get(other, person.Id) ?? 0;
|
||||||
|
if (fromView < threshold || toView < threshold)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanPair(catalog, rules, roster, years, person, other, asOf))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bonds.PartnerId = other.Id;
|
||||||
|
other.Bonds.PartnerId = person.Id;
|
||||||
|
events.Add(new AffinityEvent(person.Id, Pair, other.Id));
|
||||||
|
events.Add(new AffinityEvent(other.Id, Pair, person.Id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanSympathy(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
if (from.Orientation is null || !Attracted(catalog, from, to))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.ExcludeFamily && OpinionStore.FamilyMemberIds(roster, from).Contains(to.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RoleAllows(rules, from, to, pair: false))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.IsStudent && to.IsStudent && !StudentsClose(rules, years, from, to, asOf))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanPair(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
if (!CanSympathy(catalog, rules, roster, years, from, to, asOf)
|
||||||
|
|| !CanSympathy(catalog, rules, roster, years, to, from, asOf))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.AgeOn(asOf) < rules.PairMinAge || to.AgeOn(asOf) < rules.PairMinAge)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoleAllows(rules, from, to, pair: true) && RoleAllows(rules, to, from, pair: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanRemainPair(
|
||||||
|
DefCatalog catalog,
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
Roster roster,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
if (!CanPair(catalog, rules, roster, years, from, to, asOf))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var floor = rules.PairBreakThreshold + TraitOffset(catalog, from, breakOffset: true);
|
||||||
|
var view = OpinionStore.Get(from, to.Id) ?? 0;
|
||||||
|
return view >= floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Attracted(DefCatalog catalog, Person from, Person to)
|
||||||
|
{
|
||||||
|
if (from.Orientation is null || !catalog.Orientations.TryGetValue(from.Orientation, out var def) || def.Abstract)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return from.Female == to.Female ? def.SameGender : def.OppositeGender;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RoleAllows(AffinityRulesDef rules, Person from, Person to, bool pair)
|
||||||
|
{
|
||||||
|
var fromRole = PrimaryRole(from);
|
||||||
|
var toRole = PrimaryRole(to);
|
||||||
|
if (fromRole is null || toRole is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in rules.RolePairs)
|
||||||
|
{
|
||||||
|
if (!row.From.Equals(fromRole, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !row.To.Equals(toRole, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pair ? row.Pair : row.Sympathy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? PrimaryRole(Person person)
|
||||||
|
{
|
||||||
|
if (person.IsStudent)
|
||||||
|
{
|
||||||
|
return PersonRoles.Student;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.IsStaff)
|
||||||
|
{
|
||||||
|
return PersonRoles.Staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return person.IsParent ? PersonRoles.Parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StudentsClose(
|
||||||
|
AffinityRulesDef rules,
|
||||||
|
IReadOnlyDictionary<string, SchoolClass> years,
|
||||||
|
Person from,
|
||||||
|
Person to,
|
||||||
|
DateTime asOf)
|
||||||
|
{
|
||||||
|
if (Math.Abs(from.AgeOn(asOf) - to.AgeOn(asOf)) <= rules.StudentAgeDeltaYears)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.StudentAdjacentYear)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromYear = YearOf(years, from);
|
||||||
|
var toYear = YearOf(years, to);
|
||||||
|
return fromYear is { } left && toYear is { } right && Math.Abs(left - right) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? YearOf(IReadOnlyDictionary<string, SchoolClass> years, Person person) =>
|
||||||
|
person.ClassId is { } classId && years.TryGetValue(classId, out var schoolClass) ? schoolClass.Year : null;
|
||||||
|
|
||||||
|
private static int TraitOffset(DefCatalog catalog, Person person, bool breakOffset)
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var traitId in person.Traits)
|
||||||
|
{
|
||||||
|
if (!catalog.Traits.TryGetValue(traitId, out var trait))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += breakOffset ? trait.AffinityBreakOffset : trait.AffinityThresholdOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonBonds? EnsureBonds(Person person)
|
||||||
|
{
|
||||||
|
if (person.Orientation is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return person.Bonds ??= new PersonBonds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CooldownElapsed(PersonBonds bonds, DateTime asOf)
|
||||||
|
{
|
||||||
|
if (bonds.PairEndedOn is not { } ended)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asOf.Date > DateTime.SpecifyKind(ended, DateTimeKind.Utc).Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BreakPair(Person left, Person? right, DateTime asOf)
|
||||||
|
{
|
||||||
|
var leftBonds = EnsureBonds(left);
|
||||||
|
if (leftBonds is not null)
|
||||||
|
{
|
||||||
|
leftBonds.PartnerId = null;
|
||||||
|
leftBonds.PairEndedOn = asOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rightBonds = EnsureBonds(right);
|
||||||
|
if (rightBonds is not null)
|
||||||
|
{
|
||||||
|
rightBonds.PartnerId = null;
|
||||||
|
rightBonds.PairEndedOn = asOf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,7 +179,10 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
applicants.Add(next);
|
applicants.Add(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ApplicantPool(current.Week, nextIndex, applicants);
|
return OrientationGenerator.AssignPool(
|
||||||
|
catalog,
|
||||||
|
new ApplicantPool(current.Week, nextIndex, applicants),
|
||||||
|
schoolSeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <see cref="Person.Orientation"/> only when the catalog has orientations. Vanilla
|
||||||
|
/// catalogs skip this, so the field stays null and is omitted from people.json.
|
||||||
|
/// </summary>
|
||||||
|
public static class OrientationGenerator
|
||||||
|
{
|
||||||
|
public static Roster Assign(DefCatalog catalog, Roster roster, int schoolSeed)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
|
|
||||||
|
if (catalog.Orientations.Count == 0)
|
||||||
|
{
|
||||||
|
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 = AssignIfMissing(catalog, person, schoolSeed);
|
||||||
|
people[i] = updated;
|
||||||
|
changed |= !ReferenceEquals(updated, person);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? roster with { People = people } : roster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicantPool AssignPool(DefCatalog catalog, ApplicantPool pool, int schoolSeed)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(pool);
|
||||||
|
|
||||||
|
if (catalog.Orientations.Count == 0 || pool.Applicants.Count == 0)
|
||||||
|
{
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicants = new Applicant[pool.Applicants.Count];
|
||||||
|
var changed = false;
|
||||||
|
for (var i = 0; i < pool.Applicants.Count; i++)
|
||||||
|
{
|
||||||
|
var applicant = pool.Applicants[i];
|
||||||
|
var person = AssignIfMissing(catalog, applicant.Person, schoolSeed);
|
||||||
|
changed |= !ReferenceEquals(person, applicant.Person);
|
||||||
|
applicants[i] = ReferenceEquals(person, applicant.Person) ? applicant : applicant with { Person = person };
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? pool with { Applicants = applicants } : pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Person AssignIfMissing(DefCatalog catalog, Person person, int schoolSeed)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(person);
|
||||||
|
|
||||||
|
if (catalog.Orientations.Count == 0 || person.Orientation is not null)
|
||||||
|
{
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = Pick(catalog, person.Id, schoolSeed);
|
||||||
|
if (id is null)
|
||||||
|
{
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
return person with
|
||||||
|
{
|
||||||
|
Orientation = id,
|
||||||
|
Bonds = person.Bonds ?? new PersonBonds(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Pick(DefCatalog catalog, string personId, int schoolSeed)
|
||||||
|
{
|
||||||
|
var options = catalog.Orientations.Values
|
||||||
|
.Where(def => !def.Abstract)
|
||||||
|
.OrderBy(def => def.DefName, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (options.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = 0;
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
total += option.Weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total <= 0)
|
||||||
|
{
|
||||||
|
return options[0].DefName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rng = new Random(Seed.Mix(schoolSeed, personId, dayNumber: 0, Seed.OrientationSalt));
|
||||||
|
var roll = rng.Next(total);
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
roll -= option.Weight;
|
||||||
|
if (roll < 0)
|
||||||
|
{
|
||||||
|
return option.DefName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options[^1].DefName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,30 @@ public sealed record Person
|
|||||||
/// <summary>What this person thinks of others, A→B. Sparse; zero is not stored.</summary>
|
/// <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 IReadOnlyDictionary<string, int> Opinions { get; init; } = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OrientationDef name. Null when the catalog has no orientations (vanilla). Omitted from JSON.
|
||||||
|
/// </summary>
|
||||||
|
public string? Orientation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Crushes and the one pair. Null without an orientation pack. Mutable overlay.</summary>
|
||||||
|
public PersonBonds? Bonds { get; set; }
|
||||||
|
|
||||||
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-way crushes and at most one pair. Lives on the person when a pack shipped orientations.
|
||||||
|
/// Mutated in place the same way opinions are.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PersonBonds
|
||||||
|
{
|
||||||
|
public List<string> Crushes { get; set; } = [];
|
||||||
|
|
||||||
|
public string? PartnerId { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PairEndedOn { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record PersonName(
|
public sealed record PersonName(
|
||||||
string Given,
|
string Given,
|
||||||
string Surname,
|
string Surname,
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ public static class RosterGenerator
|
|||||||
|
|
||||||
var classes = FillClasses(demand.Classes, people);
|
var classes = FillClasses(demand.Classes, people);
|
||||||
var roster = 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);
|
roster = OrientationGenerator.Assign(catalog, OpinionGenerator.SeedFamily(catalog, roster), schoolSeed);
|
||||||
|
Affinity.Refresh(catalog, roster, when);
|
||||||
|
return roster;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public static class Seed
|
|||||||
public const int NativeLanguageSalt = 9;
|
public const int NativeLanguageSalt = 9;
|
||||||
public const int ClimatePresetSalt = 10;
|
public const int ClimatePresetSalt = 10;
|
||||||
public const int ApparelSalt = 11;
|
public const int ApparelSalt = 11;
|
||||||
|
public const int OrientationSalt = 12;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ public static class YearlyIntake
|
|||||||
|
|
||||||
var people = remainingPeople.Values.ToList();
|
var people = remainingPeople.Values.ToList();
|
||||||
var filled = AttachPupils(classes, people);
|
var filled = AttachPupils(classes, people);
|
||||||
return new Roster(people, families, filled);
|
var next = OrientationGenerator.Assign(catalog, new Roster(people, families, filled), schoolSeed);
|
||||||
|
Affinity.Refresh(catalog, next, when);
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void GrantPromotedSkills(
|
private static void GrantPromotedSkills(
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ internal sealed record CatalogResponse(
|
|||||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||||
DayFrameResponse? DayFrame,
|
DayFrameResponse? DayFrame,
|
||||||
IReadOnlyList<HolidayInfoResponse> Holidays,
|
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||||
MapLayout DefaultMap)
|
MapLayout DefaultMap,
|
||||||
|
IReadOnlyList<TopicInfoResponse> Topics)
|
||||||
{
|
{
|
||||||
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
|
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
|
||||||
new(
|
new(
|
||||||
@@ -139,7 +140,8 @@ internal sealed record CatalogResponse(
|
|||||||
PlaceableSubjects(catalog, locale),
|
PlaceableSubjects(catalog, locale),
|
||||||
MapDayFrame(catalog, locale),
|
MapDayFrame(catalog, locale),
|
||||||
PlaceableHolidays(catalog, locale),
|
PlaceableHolidays(catalog, locale),
|
||||||
map);
|
map,
|
||||||
|
PlaceableTopics(catalog, locale));
|
||||||
|
|
||||||
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
||||||
where T : Def =>
|
where T : Def =>
|
||||||
@@ -227,6 +229,16 @@ internal sealed record CatalogResponse(
|
|||||||
def.Start,
|
def.Start,
|
||||||
def.End))
|
def.End))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
private static IReadOnlyList<TopicInfoResponse> PlaceableTopics(DefCatalog catalog, string locale) =>
|
||||||
|
catalog.Topics.Values
|
||||||
|
.Where(def => !def.Abstract)
|
||||||
|
.OrderBy(def => def.DefName, StringComparer.Ordinal)
|
||||||
|
.Select(def => new TopicInfoResponse(
|
||||||
|
def.DefName,
|
||||||
|
catalog.Label(locale, def),
|
||||||
|
def.Tags.ToArray()))
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
|
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
|
||||||
@@ -270,3 +282,5 @@ internal sealed record DayFrameResponse(
|
|||||||
int LongBreakMinutes);
|
int LongBreakMinutes);
|
||||||
|
|
||||||
internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End);
|
internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End);
|
||||||
|
|
||||||
|
internal sealed record TopicInfoResponse(string DefName, string Label, IReadOnlyList<string> Tags);
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ internal sealed record PersonCardResponse(
|
|||||||
bool HasCustom = false,
|
bool HasCustom = false,
|
||||||
bool HasFullBody = false,
|
bool HasFullBody = false,
|
||||||
string? CustomPortraitPrompt = null,
|
string? CustomPortraitPrompt = null,
|
||||||
PersonConnectionsResponse? Connections = null);
|
PersonConnectionsResponse? Connections = null,
|
||||||
|
DefLabelResponse? Orientation = null);
|
||||||
|
|
||||||
internal sealed record WornItemResponse(
|
internal sealed record WornItemResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
@@ -131,7 +132,10 @@ internal sealed record PersonConnectionsResponse(
|
|||||||
PersonFamilyResponse Family,
|
PersonFamilyResponse Family,
|
||||||
IReadOnlyList<PersonOpinionLinkResponse> Friends,
|
IReadOnlyList<PersonOpinionLinkResponse> Friends,
|
||||||
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
|
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
|
||||||
IReadOnlyList<PersonOpinionLinkResponse> Others);
|
IReadOnlyList<PersonOpinionLinkResponse> Others,
|
||||||
|
IReadOnlyList<PersonOpinionLinkResponse>? Crushes = null,
|
||||||
|
IReadOnlyList<PersonOpinionLinkResponse>? Admirers = null,
|
||||||
|
PersonRelResponse? Pair = null);
|
||||||
|
|
||||||
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ internal static class PersonCardReader
|
|||||||
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));
|
Connections: Connections(roster, person, catalog, locale),
|
||||||
|
Orientation: OrientationOf(person, catalog, locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
||||||
@@ -449,7 +450,101 @@ internal static class PersonCardReader
|
|||||||
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
|
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
|
||||||
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
|
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
|
||||||
var enemies = 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);
|
var crushes = CrushesOf(person, people, catalog, locale);
|
||||||
|
var admirers = AdmirersOf(roster, person, people, catalog, locale);
|
||||||
|
var pair = PairOf(person, people, catalog, locale);
|
||||||
|
return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
if (person.Orientation is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = person.Orientation;
|
||||||
|
if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def))
|
||||||
|
{
|
||||||
|
label = catalog.Label(locale, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DefLabelResponse(person.Orientation, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PersonOpinionLinkResponse> CrushesOf(
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
if (person.Bonds is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<PersonOpinionLinkResponse>();
|
||||||
|
foreach (var id in person.Bonds.Crushes)
|
||||||
|
{
|
||||||
|
if (!people.TryGetValue(id, out var target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opinion = OpinionStore.Get(person, id) ?? 0;
|
||||||
|
rows.Add(Link(target, opinion, catalog, locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PersonOpinionLinkResponse> AdmirersOf(
|
||||||
|
Roster roster,
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<PersonOpinionLinkResponse>();
|
||||||
|
foreach (var other in roster.People)
|
||||||
|
{
|
||||||
|
if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opinion = OpinionStore.Get(other, person.Id) ?? 0;
|
||||||
|
rows.Add(Link(other, opinion, catalog, locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelResponse? PairOf(
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? opinion = null;
|
||||||
|
string? label = null;
|
||||||
|
if (person.Opinions.TryGetValue(partnerId, out var value))
|
||||||
|
{
|
||||||
|
opinion = value;
|
||||||
|
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PersonFamilyResponse FamilyWithOpinions(
|
private static PersonFamilyResponse FamilyWithOpinions(
|
||||||
|
|||||||
@@ -996,6 +996,17 @@ internal sealed class SchoolWorker
|
|||||||
generated = true;
|
generated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var assigned = OrientationGenerator.Assign(catalog, roster, seed);
|
||||||
|
generated |= !ReferenceEquals(assigned, roster);
|
||||||
|
roster = assigned;
|
||||||
|
var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed);
|
||||||
|
generated |= !ReferenceEquals(assignedPool, applicants);
|
||||||
|
applicants = assignedPool;
|
||||||
|
if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0)
|
||||||
|
{
|
||||||
|
generated = true;
|
||||||
|
}
|
||||||
|
|
||||||
roster = LockerAssigner.Apply(catalog, map, roster);
|
roster = LockerAssigner.Apply(catalog, map, roster);
|
||||||
|
|
||||||
if (!RosterFit.Matches(roster, demand))
|
if (!RosterFit.Matches(roster, demand))
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Romance pack
|
||||||
|
|
||||||
|
A content pack, not a sample folder. Tick it in create. Vanilla schools stay without
|
||||||
|
orientation, crushes, pairs, or these topics.
|
||||||
|
|
||||||
|
`example` is the layout sample; this pack is the living one. Do not copy content into
|
||||||
|
`example`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
romance/
|
||||||
|
pack.jsonc # version + requires: ["core"]
|
||||||
|
localizations/{ru,en}.jsonc
|
||||||
|
defs/orientations/ # OrientationDef + generator weights
|
||||||
|
defs/affinity/rules.jsonc # AffinityRulesDef singleton — numbers, not pack ids in code
|
||||||
|
defs/traits/traits.jsonc # crush-prone / jealous via TraitDef fields
|
||||||
|
defs/topics/romance.jsonc # topics that exist only with this pack
|
||||||
|
patches/topic-appearance.jsonc
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Core already picks every `TopicDef` in the catalog for `Chat`. New topics join that pool;
|
||||||
|
`Chat` itself is not rewritten. The appearance topic gets a `romance` tag so crush-prone
|
||||||
|
people lean toward it — that is a patch, not a copy of core.
|
||||||
|
|
||||||
|
Without this pack the catalog has no `OrientationDef` and no `AffinityRulesDef`. The
|
||||||
|
generator then writes no orientation field, and the affinity overlay does not run.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"defName": "AffinityRules",
|
||||||
|
"sympathyThreshold": 50,
|
||||||
|
"pairThreshold": 70,
|
||||||
|
"pairBreakThreshold": 20,
|
||||||
|
"pairMinAge": 18,
|
||||||
|
"studentAgeDeltaYears": 2,
|
||||||
|
"studentAdjacentYear": true,
|
||||||
|
"excludeFamily": true,
|
||||||
|
"rebuffOpinionShift": -8,
|
||||||
|
"rolePairs": [
|
||||||
|
{ "from": "student", "to": "student", "sympathy": true, "pair": true },
|
||||||
|
{ "from": "student", "to": "staff", "sympathy": true, "pair": false },
|
||||||
|
{ "from": "staff", "to": "student", "sympathy": false, "pair": false },
|
||||||
|
{ "from": "staff", "to": "staff", "sympathy": true, "pair": true },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"defName": "Heterosexual",
|
||||||
|
"weight": 70,
|
||||||
|
"oppositeGender": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "Bisexual",
|
||||||
|
"weight": 20,
|
||||||
|
"sameGender": true,
|
||||||
|
"oppositeGender": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "Homosexual",
|
||||||
|
"weight": 10,
|
||||||
|
"sameGender": true,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"defName": "TopicCrush",
|
||||||
|
"tags": ["romance", "gossip"],
|
||||||
|
"roles": ["student"],
|
||||||
|
"age": { "min": 11, "max": 18 },
|
||||||
|
"opinionShift": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "TopicCouple",
|
||||||
|
"tags": ["romance"],
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"age": { "min": 18 },
|
||||||
|
"opinionShift": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "TopicTease",
|
||||||
|
"tags": ["romance", "gossip"],
|
||||||
|
"roles": ["student"],
|
||||||
|
"age": { "min": 7, "max": 18 },
|
||||||
|
"opinionShift": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"defName": "CrushProne",
|
||||||
|
"weight": 5,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"affinityThresholdOffset": -15,
|
||||||
|
"talkTagWeights": [{ "tag": "romance", "weight": 3 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "Jealous",
|
||||||
|
"weight": 4,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"age": { "min": 14 },
|
||||||
|
"affinityBreakOffset": 15,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"romance": "Romance",
|
||||||
|
"Heterosexual": "heterosexual",
|
||||||
|
"Bisexual": "bisexual",
|
||||||
|
"Homosexual": "homosexual",
|
||||||
|
"AffinityRules": "Affinity rules",
|
||||||
|
"CrushProne": "crush-prone",
|
||||||
|
"Jealous": "jealous",
|
||||||
|
"TopicCrush": "who likes whom",
|
||||||
|
"TopicCouple": "couples",
|
||||||
|
"TopicTease": "teasing",
|
||||||
|
"AffinityCrush": "a crush started",
|
||||||
|
"AffinityPair": "became a couple",
|
||||||
|
"AffinityPairBreak": "the couple split",
|
||||||
|
"AffinityTease": "teasing in class",
|
||||||
|
"AffinityRebuff": "turned down in talk",
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"romance": "Романтика",
|
||||||
|
"Heterosexual": "гетеро",
|
||||||
|
"Bisexual": "би",
|
||||||
|
"Homosexual": "гомо",
|
||||||
|
"AffinityRules": "Правила симпатии",
|
||||||
|
"CrushProne": "влюбчивый",
|
||||||
|
"Jealous": "ревнивый",
|
||||||
|
"TopicCrush": "кто кому нравится",
|
||||||
|
"TopicCouple": "пары",
|
||||||
|
"TopicTease": "дразнилки",
|
||||||
|
"AffinityCrush": "появилась симпатия",
|
||||||
|
"AffinityPair": "стали парой",
|
||||||
|
"AffinityPairBreak": "пара распалась",
|
||||||
|
"AffinityTease": "дразнилки в классе",
|
||||||
|
"AffinityRebuff": "разговор-отказ",
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"requires": ["core"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"target": "TopicAppearance",
|
||||||
|
"ops": [
|
||||||
|
{ "op": "add", "path": "/tags/-", "value": "romance" },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the catalog's affinity overlay after talk and morning opinion shifts.
|
||||||
|
/// No-ops when the catalog has no affinity rules.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AffinitySystem
|
||||||
|
{
|
||||||
|
public static bool Apply(School school, IReadOnlyList<string>? justTalked = null)
|
||||||
|
{
|
||||||
|
if (school.Catalog is null || school.Roster is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = new List<AffinityEvent>();
|
||||||
|
events.AddRange(Affinity.Refresh(school.Catalog, school.Roster, school.Clock.Time));
|
||||||
|
if (justTalked is { Count: > 0 })
|
||||||
|
{
|
||||||
|
events.AddRange(Affinity.RebuffTalk(school.Catalog, school.Roster, justTalked, school.Clock.Time));
|
||||||
|
if (events.Count > 0)
|
||||||
|
{
|
||||||
|
events.AddRange(Affinity.Refresh(school.Catalog, school.Roster, school.Clock.Time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in events)
|
||||||
|
{
|
||||||
|
school.AppendDayLog(new PersonLogEvent(row.PersonId, school.Clock.Time, row.Type, row.OtherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
|
|||||||
ArgumentNullException.ThrowIfNull(catalog);
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
if (ThingDef is null)
|
if (ThingDef is null)
|
||||||
{
|
{
|
||||||
return Type;
|
var emptyKey = TypeToLocaleKey(Type);
|
||||||
|
return catalog.HasText(locale, emptyKey) ? catalog.Text(locale, emptyKey) : Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
|
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
|
||||||
@@ -85,8 +86,35 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
|
|||||||
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic);
|
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var localeKey = TypeToLocaleKey(Type);
|
||||||
|
if (catalog.HasText(locale, localeKey))
|
||||||
|
{
|
||||||
|
var text = catalog.Text(locale, localeKey);
|
||||||
|
return string.Format(CultureInfo.InvariantCulture, text, ThingDef);
|
||||||
|
}
|
||||||
|
|
||||||
return Type;
|
return Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string TypeToLocaleKey(string type)
|
||||||
|
{
|
||||||
|
var chars = new char[type.Length];
|
||||||
|
var length = 0;
|
||||||
|
var upper = true;
|
||||||
|
foreach (var character in type)
|
||||||
|
{
|
||||||
|
if (character == '-')
|
||||||
|
{
|
||||||
|
upper = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
chars[length++] = upper ? char.ToUpperInvariant(character) : character;
|
||||||
|
upper = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(chars, 0, length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
|
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
|
||||||
|
|||||||
@@ -360,7 +360,8 @@ public sealed class School : IDisposable
|
|||||||
PresenceSystem.Enqueue(this, id);
|
PresenceSystem.Enqueue(this, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var id in TalkCircleSystem.Apply(this, gameMinutes))
|
var talked = TalkCircleSystem.Apply(this, gameMinutes);
|
||||||
|
foreach (var id in talked)
|
||||||
{
|
{
|
||||||
PresenceSystem.Enqueue(this, id);
|
PresenceSystem.Enqueue(this, id);
|
||||||
}
|
}
|
||||||
@@ -376,6 +377,7 @@ public sealed class School : IDisposable
|
|||||||
PresenceSystem.DrainDecisions(this);
|
PresenceSystem.DrainDecisions(this);
|
||||||
LessonLearningSystem.Apply(this, gameMinutes);
|
LessonLearningSystem.Apply(this, gameMinutes);
|
||||||
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
||||||
|
peopleChanged |= AffinitySystem.Apply(this, talked);
|
||||||
}
|
}
|
||||||
|
|
||||||
return peopleChanged;
|
return peopleChanged;
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class RomancePackTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Catalog_WithoutRomance_HasNoRomanticTopics()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
|
||||||
|
var vanilla = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
|
||||||
|
var example = await client.GetFromJsonAsync<CatalogResponse>(
|
||||||
|
"/api/catalog?lang=ru&mods=example",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(vanilla);
|
||||||
|
Assert.NotNull(example);
|
||||||
|
Assert.DoesNotContain(vanilla.Topics, topic => topic.DefName is "TopicCrush" or "TopicCouple" or "TopicTease");
|
||||||
|
Assert.DoesNotContain(vanilla.Topics, topic => topic.Tags.Contains("romance", StringComparer.Ordinal));
|
||||||
|
Assert.DoesNotContain(example.Topics, topic => topic.DefName is "TopicCrush" or "TopicCouple" or "TopicTease");
|
||||||
|
Assert.DoesNotContain(example.Topics, topic => topic.Tags.Contains("romance", StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Catalog_WithRomance_AddsPackTopics()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
var catalog = await client.GetFromJsonAsync<CatalogResponse>(
|
||||||
|
"/api/catalog?lang=ru&mods=romance",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(catalog);
|
||||||
|
Assert.Contains(catalog.Topics, topic => topic.DefName == "TopicCrush");
|
||||||
|
Assert.Contains(catalog.Topics, topic => topic.Tags.Contains("romance", StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithPack_WritesOrientation_WithoutPack_OmitsField()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
|
||||||
|
var vanilla = await SchoolApiTests.CreateAsync(client, "Без романтики", Start, seed: 47);
|
||||||
|
var vanillaJson = await ReadPeopleJsonAsync(client, vanilla.Id);
|
||||||
|
Assert.DoesNotContain("\"orientation\"", vanillaJson, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var packed = await CreateWithModsAsync(client, "С романтикой", ["romance"]);
|
||||||
|
var packedJson = await ReadPeopleJsonAsync(client, packed.Id);
|
||||||
|
Assert.Contains("\"orientation\"", packedJson, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{packed.Id}/people?role=student&pageSize=5",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
var card = await client.GetFromJsonAsync<Card>(
|
||||||
|
$"/api/schools/{packed.Id}/people/{Uri.EscapeDataString(page.People[0].Id)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(card.Orientation?.DefName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SixteenYearOlds_DoNotPair_WhenOpinionsAreMax()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await CreateWithModsAsync(client, "Шестнадцать", ["romance"]);
|
||||||
|
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&year=10&pageSize=40",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
Assert.True(page.People.Count >= 2, "need two tenth-years");
|
||||||
|
var first = page.People[0];
|
||||||
|
var second = page.People.First(person => person.Id != first.Id);
|
||||||
|
|
||||||
|
await PatchPeopleAsync(client, school.Id, people =>
|
||||||
|
{
|
||||||
|
Link(people, first.Id, second.Id, 100);
|
||||||
|
Link(people, second.Id, first.Id, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<Card>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(first.Id)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.Null(card.Connections?.Pair);
|
||||||
|
Assert.DoesNotContain("\"partnerId\"", await ReadPeopleJsonAsync(client, school.Id), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Teacher_DoesNotGainCrushOnStudent()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await CreateWithModsAsync(client, "Учитель", ["romance"]);
|
||||||
|
|
||||||
|
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
|
||||||
|
$"/api/schools/{school.Id}/staffing",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(staffing);
|
||||||
|
var applicant = staffing.Applicants.First(row => !row.IsParent);
|
||||||
|
using var hire = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/staff/hire",
|
||||||
|
new { personId = applicant.Id, position = "Teacher" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
hire.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var pupils = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&pageSize=5",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(pupils);
|
||||||
|
var pupilId = pupils.People[0].Id;
|
||||||
|
|
||||||
|
await PatchPeopleAsync(client, school.Id, people => Link(people, applicant.Id, pupilId, 100));
|
||||||
|
|
||||||
|
var json = await ReadPeopleJsonAsync(client, school.Id);
|
||||||
|
Assert.DoesNotContain($"\"partnerId\": \"{pupilId}\"", json, StringComparison.Ordinal);
|
||||||
|
var teacher = JsonNode.Parse(json)!["people"]!.AsArray()
|
||||||
|
.First(node => node!["id"]?.GetValue<string>() == applicant.Id);
|
||||||
|
var crushes = teacher!["bonds"]?["crushes"]?.AsArray() ?? [];
|
||||||
|
Assert.DoesNotContain(crushes, node => node?.GetValue<string>() == pupilId);
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<Card>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(applicant.Id)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.DoesNotContain(card.Connections?.Crushes ?? [], row => row.Id == pupilId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FirstYear_DoesNotCrushEleventh()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await CreateWithModsAsync(client, "Параллели", ["romance"]);
|
||||||
|
|
||||||
|
var first = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&year=1&pageSize=5",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var eleventh = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&year=11&pageSize=5",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(eleventh);
|
||||||
|
Assert.NotEmpty(first.People);
|
||||||
|
Assert.NotEmpty(eleventh.People);
|
||||||
|
var youngId = first.People[0].Id;
|
||||||
|
var oldId = eleventh.People[0].Id;
|
||||||
|
|
||||||
|
await PatchPeopleAsync(client, school.Id, people => Link(people, youngId, oldId, 100));
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<Card>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(youngId)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.DoesNotContain(card.Connections?.Crushes ?? [], row => row.Id == oldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SchoolApiTests.SchoolResponse> CreateWithModsAsync(
|
||||||
|
HttpClient client,
|
||||||
|
string name,
|
||||||
|
string[] modIds)
|
||||||
|
{
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
"/api/schools",
|
||||||
|
new { name, startDate = Start, modIds, seed = 47 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var created = await response.Content.ReadFromJsonAsync<SchoolApiTests.SchoolResponse>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadPeopleJsonAsync(HttpClient client, int schoolId)
|
||||||
|
{
|
||||||
|
var directory = await SchoolApiTests.SavesDirectoryAsync(client);
|
||||||
|
return await File.ReadAllTextAsync(
|
||||||
|
Path.Combine(directory, $"{schoolId}.people.json"),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PatchPeopleAsync(HttpClient client, int schoolId, Action<JsonArray> patch)
|
||||||
|
{
|
||||||
|
var directory = await SchoolApiTests.SavesDirectoryAsync(client);
|
||||||
|
var path = Path.Combine(directory, $"{schoolId}.people.json");
|
||||||
|
var doc = JsonNode.Parse(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken))!;
|
||||||
|
var people = doc["people"]!.AsArray();
|
||||||
|
patch(people);
|
||||||
|
await File.WriteAllTextAsync(path, doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
|
reload.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Link(JsonArray people, string fromId, string toId, int opinion)
|
||||||
|
{
|
||||||
|
var from = people.First(node => node!["id"]?.GetValue<string>() == fromId)!.AsObject();
|
||||||
|
from["orientation"] = "Bisexual";
|
||||||
|
var opinions = from["opinions"] as JsonObject ?? new JsonObject();
|
||||||
|
opinions[toId] = opinion;
|
||||||
|
from["opinions"] = opinions;
|
||||||
|
var bonds = from["bonds"] as JsonObject ?? new JsonObject();
|
||||||
|
bonds["crushes"] = new JsonArray();
|
||||||
|
from["bonds"] = bonds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CatalogResponse(IReadOnlyList<TopicInfo> Topics);
|
||||||
|
|
||||||
|
private sealed record TopicInfo(string DefName, string Label, IReadOnlyList<string> Tags);
|
||||||
|
|
||||||
|
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||||
|
|
||||||
|
private sealed record PersonRow(string Id);
|
||||||
|
|
||||||
|
private sealed record Card(string Id, DefLabel? Orientation, PersonConnections? Connections);
|
||||||
|
|
||||||
|
private sealed record DefLabel(string DefName, string Label);
|
||||||
|
|
||||||
|
private sealed record PersonConnections(
|
||||||
|
IReadOnlyList<PersonLink>? Crushes,
|
||||||
|
PersonLink? Pair);
|
||||||
|
|
||||||
|
private sealed record PersonLink(string Id);
|
||||||
|
|
||||||
|
private sealed record StaffingResponse(IReadOnlyList<ApplicantRow> Applicants);
|
||||||
|
|
||||||
|
private sealed record ApplicantRow(string Id, bool IsParent);
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@
|
|||||||
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\romance\**\*">
|
||||||
|
<Link>romance\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
public class RomancePackTests
|
||||||
|
{
|
||||||
|
private const string RomancePackId = "romance";
|
||||||
|
|
||||||
|
private readonly CatalogLoader _loader = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VanillaCatalog_HasNoOrientationsOrRomanceTopics()
|
||||||
|
{
|
||||||
|
var vanilla = LoadCore();
|
||||||
|
Assert.Empty(vanilla.Orientations);
|
||||||
|
Assert.Null(vanilla.AffinityRules);
|
||||||
|
Assert.DoesNotContain(vanilla.Topics.Keys, id => id.StartsWith("TopicCrush", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain(vanilla.Topics.Values, topic => topic.Tags.Contains("romance", StringComparer.Ordinal));
|
||||||
|
Assert.False(vanilla.Traits.ContainsKey("CrushProne"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pack_LoadsOrientationsTraitsTopicsAndAffinityRules()
|
||||||
|
{
|
||||||
|
var catalog = LoadCoreAndRomance();
|
||||||
|
Assert.True(catalog.Orientations.ContainsKey("Heterosexual"));
|
||||||
|
Assert.True(catalog.Orientations.ContainsKey("Bisexual"));
|
||||||
|
Assert.True(catalog.Orientations.ContainsKey("Homosexual"));
|
||||||
|
Assert.NotNull(catalog.AffinityRules);
|
||||||
|
Assert.Equal(18, catalog.AffinityRules!.PairMinAge);
|
||||||
|
Assert.True(catalog.Traits.ContainsKey("CrushProne"));
|
||||||
|
Assert.True(catalog.Traits.ContainsKey("Jealous"));
|
||||||
|
Assert.Equal(-15, catalog.Traits["CrushProne"].AffinityThresholdOffset);
|
||||||
|
Assert.True(catalog.Topics.ContainsKey("TopicCrush"));
|
||||||
|
Assert.True(catalog.Topics.ContainsKey("TopicCouple"));
|
||||||
|
Assert.True(catalog.Topics.ContainsKey("TopicTease"));
|
||||||
|
Assert.Contains("romance", catalog.Topics["TopicAppearance"].Tags);
|
||||||
|
Assert.Equal("Романтика", catalog.Text("ru", "romance"));
|
||||||
|
Assert.Equal("Romance", catalog.Text("en", "romance"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExampleWithoutRomance_KeepsVanillaTopics()
|
||||||
|
{
|
||||||
|
var vanilla = LoadCore();
|
||||||
|
var example = LoadCoreAndExample();
|
||||||
|
Assert.Equal(
|
||||||
|
vanilla.Topics.Keys.OrderBy(id => id, StringComparer.Ordinal),
|
||||||
|
example.Topics.Keys.OrderBy(id => id, StringComparer.Ordinal));
|
||||||
|
Assert.Empty(example.Orientations);
|
||||||
|
Assert.DoesNotContain(example.Topics.Values, topic => topic.Tags.Contains("romance", StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefCatalog LoadCore() =>
|
||||||
|
_loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()));
|
||||||
|
|
||||||
|
private DefCatalog LoadCoreAndRomance() =>
|
||||||
|
_loader.Load(
|
||||||
|
[CatalogLoader.CorePackId, RomancePackId],
|
||||||
|
[
|
||||||
|
.. PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()),
|
||||||
|
.. PackDocuments.FromDirectory(RomancePackId, RomanceRoot()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
private DefCatalog LoadCoreAndExample() =>
|
||||||
|
_loader.Load(
|
||||||
|
[CatalogLoader.CorePackId, "example"],
|
||||||
|
[
|
||||||
|
.. PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()),
|
||||||
|
.. PackDocuments.FromDirectory("example", ExampleRoot()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static string CoreRoot() => Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
|
||||||
|
private static string RomanceRoot()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "romance");
|
||||||
|
Assert.True(Directory.Exists(root), $"Romance pack was not copied to {root}.");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExampleRoot() => Path.Combine(AppContext.BaseDirectory, "example");
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
namespace HSchool.People.Tests;
|
||||||
|
|
||||||
|
public class AffinityTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime AsOf = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SixteenYearOlds_NeverPair_AtAnyOpinion()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var boy = Pupil("a", year: 10, age: 16, female: false, "Bisexual");
|
||||||
|
var girl = Pupil("b", year: 10, age: 16, female: true, "Bisexual");
|
||||||
|
SetOpinion(boy, girl.Id, 100);
|
||||||
|
SetOpinion(girl, boy.Id, 100);
|
||||||
|
var roster = RosterOf(boy, girl, classYear: 10);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
|
||||||
|
Assert.Contains("b", boy.Bonds!.Crushes);
|
||||||
|
Assert.Contains("a", girl.Bonds!.Crushes);
|
||||||
|
Assert.Null(boy.Bonds.PartnerId);
|
||||||
|
Assert.Null(girl.Bonds.PartnerId);
|
||||||
|
Assert.False(Affinity.CanFormPair(catalog, roster, boy, girl, AsOf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstYear_DoesNotCrushEleventh()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var young = Pupil("c1", year: 1, age: 7, female: true, "Bisexual", classId: "y1");
|
||||||
|
var old = Pupil("c11", year: 11, age: 17, female: false, "Bisexual", classId: "y11");
|
||||||
|
SetOpinion(young, old.Id, 100);
|
||||||
|
var roster = new Roster(
|
||||||
|
[young, old],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
new SchoolClass("y1", 1, "А", "r1", 16, [young.Id]),
|
||||||
|
new SchoolClass("y11", 11, "А", "r11", 16, [old.Id]),
|
||||||
|
]);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("c11", young.Bonds!.Crushes);
|
||||||
|
Assert.False(Affinity.CanHaveSympathy(catalog, roster, young, old, AsOf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Staff_NeverGetsCrushOnStudent()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var teacher = Staff("t", female: false, "Bisexual");
|
||||||
|
var pupil = Pupil("p", year: 8, age: 14, female: true, "Bisexual");
|
||||||
|
SetOpinion(teacher, pupil.Id, 100);
|
||||||
|
SetOpinion(pupil, teacher.Id, 100);
|
||||||
|
var roster = RosterOf(teacher, pupil, classYear: 8);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(pupil.Id, teacher.Bonds!.Crushes);
|
||||||
|
Assert.Contains(teacher.Id, pupil.Bonds!.Crushes);
|
||||||
|
Assert.Null(teacher.Bonds.PartnerId);
|
||||||
|
Assert.Null(pupil.Bonds.PartnerId);
|
||||||
|
Assert.False(Affinity.CanHaveSympathy(catalog, roster, teacher, pupil, AsOf));
|
||||||
|
Assert.True(Affinity.CanHaveSympathy(catalog, roster, pupil, teacher, AsOf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaffPair_IsAllowedWhenBothAdults()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var left = Staff("s1", female: false, "Bisexual");
|
||||||
|
var right = Staff("s2", female: true, "Bisexual");
|
||||||
|
SetOpinion(left, right.Id, 80);
|
||||||
|
SetOpinion(right, left.Id, 80);
|
||||||
|
var roster = new Roster([left, right], [], []);
|
||||||
|
var events = Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
|
||||||
|
Assert.Equal(right.Id, left.Bonds!.PartnerId);
|
||||||
|
Assert.Equal(left.Id, right.Bonds!.PartnerId);
|
||||||
|
Assert.Contains(events, row => row.Type == Affinity.Pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PairBreak_BlocksNewPairUntilNextDay()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var left = Staff("s1", female: false, "Bisexual");
|
||||||
|
var right = Staff("s2", female: true, "Bisexual");
|
||||||
|
var third = Staff("s3", female: true, "Bisexual");
|
||||||
|
SetOpinion(left, right.Id, 80);
|
||||||
|
SetOpinion(right, left.Id, 80);
|
||||||
|
var roster = new Roster([left, right, third], [], []);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
Assert.Equal(right.Id, left.Bonds!.PartnerId);
|
||||||
|
|
||||||
|
SetOpinion(left, right.Id, 0);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
Assert.Null(left.Bonds.PartnerId);
|
||||||
|
Assert.NotNull(left.Bonds.PairEndedOn);
|
||||||
|
|
||||||
|
SetOpinion(left, third.Id, 80);
|
||||||
|
SetOpinion(third, left.Id, 80);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
Assert.Null(left.Bonds.PartnerId);
|
||||||
|
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf.AddDays(1));
|
||||||
|
Assert.Equal(third.Id, left.Bonds.PartnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjacentYearStudents_CanHaveSympathy()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var younger = Pupil("y", year: 9, age: 15, female: true, "Bisexual", classId: "c9");
|
||||||
|
var older = Pupil("o", year: 10, age: 18, female: false, "Bisexual", classId: "c10");
|
||||||
|
SetOpinion(younger, older.Id, 80);
|
||||||
|
var roster = new Roster(
|
||||||
|
[younger, older],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
new SchoolClass("c9", 9, "А", "r9", 16, [younger.Id]),
|
||||||
|
new SchoolClass("c10", 10, "А", "r10", 16, [older.Id]),
|
||||||
|
]);
|
||||||
|
Assert.True(Affinity.CanHaveSympathy(catalog, roster, younger, older, AsOf));
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
Assert.Contains(older.Id, younger.Bonds!.Crushes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetOpinion(Person person, string targetId, int value)
|
||||||
|
{
|
||||||
|
if (value == 0)
|
||||||
|
{
|
||||||
|
OpinionStore.Remove(person, targetId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpinionStore.Set(person, targetId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Roster RosterOf(Person first, Person second, int classYear)
|
||||||
|
{
|
||||||
|
var pupils = new[] { first, second }.Where(person => person.IsStudent).Select(person => person.Id).ToArray();
|
||||||
|
return new Roster(
|
||||||
|
[first, second],
|
||||||
|
[],
|
||||||
|
[new SchoolClass("c", classYear, "А", "room", 16, pupils)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person Pupil(string id, int year, int age, bool female, string orientation, string? classId = null)
|
||||||
|
{
|
||||||
|
_ = year;
|
||||||
|
var birth = AsOf.AddYears(-age);
|
||||||
|
return AdultLike(id, female, orientation, isStudent: true, isStaff: false, birth, classId ?? "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person Staff(string id, bool female, string orientation) =>
|
||||||
|
AdultLike(id, female, orientation, isStudent: false, isStaff: true, AsOf.AddYears(-30), classId: null);
|
||||||
|
|
||||||
|
private static Person AdultLike(
|
||||||
|
string id,
|
||||||
|
bool female,
|
||||||
|
string orientation,
|
||||||
|
bool isStudent,
|
||||||
|
bool isStaff,
|
||||||
|
DateTime birth,
|
||||||
|
string? classId)
|
||||||
|
{
|
||||||
|
var cases = new CaseTable
|
||||||
|
{
|
||||||
|
Nom = id,
|
||||||
|
Gen = id,
|
||||||
|
Dat = id,
|
||||||
|
Acc = id,
|
||||||
|
Ins = id,
|
||||||
|
Pre = id,
|
||||||
|
};
|
||||||
|
return new Person
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
FamilyId = "f",
|
||||||
|
Female = female,
|
||||||
|
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
|
||||||
|
Name = new PersonName(id, id, id, cases, cases, cases),
|
||||||
|
IsStudent = isStudent,
|
||||||
|
IsStaff = isStaff,
|
||||||
|
IsParent = false,
|
||||||
|
ClassId = classId,
|
||||||
|
Numbers = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Traits = [],
|
||||||
|
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
|
||||||
|
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Orientation = orientation,
|
||||||
|
Bonds = new PersonBonds(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,19 @@ internal static class Fixtures
|
|||||||
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DefCatalog RomanceCatalog()
|
||||||
|
{
|
||||||
|
var vanilla = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
var romance = Path.Combine(AppContext.BaseDirectory, "romance");
|
||||||
|
Assert.True(Directory.Exists(romance), $"Romance pack was not copied to {romance}.");
|
||||||
|
return new CatalogLoader().Load(
|
||||||
|
[CatalogLoader.CorePackId, "romance"],
|
||||||
|
[
|
||||||
|
.. PackDocuments.FromDirectory(CatalogLoader.CorePackId, vanilla),
|
||||||
|
.. PackDocuments.FromDirectory("romance", romance),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public static MapLayout Classrooms(int count, int desks = 16)
|
public static MapLayout Classrooms(int count, int desks = 16)
|
||||||
{
|
{
|
||||||
var rooms = new RoomNode[count];
|
var rooms = new RoomNode[count];
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\romance\**\*">
|
||||||
|
<Link>romance\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<Content Include="golden\**\*">
|
<Content Include="golden\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace HSchool.People.Tests;
|
||||||
|
|
||||||
|
public class OrientationGeneratorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void VanillaGenerate_DoesNotWriteOrientation()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.Classrooms(2));
|
||||||
|
Assert.All(roster.People, person => Assert.Null(person.Orientation));
|
||||||
|
Assert.All(roster.People, person => Assert.Null(person.Bonds));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RomanceGenerate_WritesOrientationToEveryone()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, Fixtures.Classrooms(2), Fixtures.SchoolSeed, "Russia", Fixtures.AsOf, "RussianLanguage");
|
||||||
|
Assert.NotEmpty(roster.People);
|
||||||
|
Assert.All(roster.People, person =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(person.Orientation));
|
||||||
|
Assert.True(catalog.Orientations.ContainsKey(person.Orientation!));
|
||||||
|
Assert.NotNull(person.Bonds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameSeed_YieldsTheSameOrientations()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var a = RosterGenerator.Generate(catalog, Fixtures.Classrooms(2), Fixtures.SchoolSeed, "Russia", Fixtures.AsOf, "RussianLanguage");
|
||||||
|
var b = RosterGenerator.Generate(catalog, Fixtures.Classrooms(2), Fixtures.SchoolSeed, "Russia", Fixtures.AsOf, "RussianLanguage");
|
||||||
|
Assert.Equal(
|
||||||
|
a.People.Select(person => (person.Id, person.Orientation)),
|
||||||
|
b.People.Select(person => (person.Id, person.Orientation)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user