From d2911c3daf6b95c103a9340a184a12ed303b38b1 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:42:42 +0300 Subject: [PATCH 1/2] 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. --- docs/design/defs.md | 7 + docs/phases/47-romance-pack.md | 30 +- docs/protocol.md | 14 +- src/HSchool.Client/src/i18n/strings.ts | 8 + src/HSchool.Client/src/net/api.ts | 12 + src/HSchool.Client/src/ui/personCard.test.ts | 29 + src/HSchool.Client/src/ui/personCard.ts | 17 + src/HSchool.Content/CatalogLoader.cs | 15 +- src/HSchool.Content/DefCatalog.cs | 15 + src/HSchool.Content/Defs.cs | 2 + src/HSchool.Content/PackPaths.cs | 6 + src/HSchool.Content/PeopleDefValidator.cs | 69 +++ src/HSchool.Content/PeopleDefs.cs | 77 +++ src/HSchool.People/Affinity.cs | 494 ++++++++++++++++++ src/HSchool.People/ApplicantPool.cs | 5 +- src/HSchool.People/OrientationGenerator.cs | 115 ++++ src/HSchool.People/Roster.cs | 21 + src/HSchool.People/RosterGenerator.cs | 4 +- src/HSchool.People/Seed.cs | 1 + src/HSchool.People/YearlyIntake.cs | 4 +- src/HSchool.Server/Api/ModEndpoints.cs | 18 +- src/HSchool.Server/Api/PeopleModels.cs | 8 +- src/HSchool.Server/Game/PersonCardReader.cs | 99 +++- src/HSchool.Server/Game/SchoolWorker.cs | 11 + src/HSchool.Server/mods/romance/README.md | 28 + .../mods/romance/defs/affinity/rules.jsonc | 17 + .../defs/orientations/orientations.jsonc | 18 + .../mods/romance/defs/topics/romance.jsonc | 23 + .../mods/romance/defs/traits/traits.jsonc | 16 + .../mods/romance/localizations/en.jsonc | 17 + .../mods/romance/localizations/ru.jsonc | 17 + src/HSchool.Server/mods/romance/pack.jsonc | 4 + .../romance/patches/topic-appearance.jsonc | 6 + src/HSchool.Simulation/AffinitySystem.cs | 36 ++ src/HSchool.Simulation/PersonLog.cs | 30 +- src/HSchool.Simulation/School.cs | 4 +- .../HSchool.AppHost.Tests/RomancePackTests.cs | 236 +++++++++ .../HSchool.Content.Tests.csproj | 4 + .../HSchool.Content.Tests/RomancePackTests.cs | 83 +++ tests/HSchool.People.Tests/AffinityTests.cs | 195 +++++++ tests/HSchool.People.Tests/Fixtures.cs | 13 + .../HSchool.People.Tests.csproj | 4 + .../OrientationGeneratorTests.cs | 37 ++ 43 files changed, 1840 insertions(+), 29 deletions(-) create mode 100644 src/HSchool.People/Affinity.cs create mode 100644 src/HSchool.People/OrientationGenerator.cs create mode 100644 src/HSchool.Server/mods/romance/README.md create mode 100644 src/HSchool.Server/mods/romance/defs/affinity/rules.jsonc create mode 100644 src/HSchool.Server/mods/romance/defs/orientations/orientations.jsonc create mode 100644 src/HSchool.Server/mods/romance/defs/topics/romance.jsonc create mode 100644 src/HSchool.Server/mods/romance/defs/traits/traits.jsonc create mode 100644 src/HSchool.Server/mods/romance/localizations/en.jsonc create mode 100644 src/HSchool.Server/mods/romance/localizations/ru.jsonc create mode 100644 src/HSchool.Server/mods/romance/pack.jsonc create mode 100644 src/HSchool.Server/mods/romance/patches/topic-appearance.jsonc create mode 100644 src/HSchool.Simulation/AffinitySystem.cs create mode 100644 tests/HSchool.AppHost.Tests/RomancePackTests.cs create mode 100644 tests/HSchool.Content.Tests/RomancePackTests.cs create mode 100644 tests/HSchool.People.Tests/AffinityTests.cs create mode 100644 tests/HSchool.People.Tests/OrientationGeneratorTests.cs diff --git a/docs/design/defs.md b/docs/design/defs.md index 7fdd9de..75e4f2b 100644 --- a/docs/design/defs.md +++ b/docs/design/defs.md @@ -237,6 +237,13 @@ mods/ localizations/ ru.jsonc en.jsonc + romance/ # контентный пак: ориентация, симпатия, пары 18+, темы + defs/orientations/ + defs/affinity/ + defs/traits/ + defs/topics/ + patches/ + localizations/ ``` Порядок загрузки: сначала `core`, потом выбранные моды в том порядке, в каком они стоят в diff --git a/docs/phases/47-romance-pack.md b/docs/phases/47-romance-pack.md index 703332f..d17c1ae 100644 --- a/docs/phases/47-romance-pack.md +++ b/docs/phases/47-romance-pack.md @@ -13,27 +13,27 @@ ## Задачи -- [ ] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные, +- [x] `mods/romance/`: `pack.jsonc` `requires: ["core"]`, локали, ориентация как данные, черты влюбчивый/ревнивый полями, патч тем и при необходимости `Chat` -- [ ] Без пака в create ориентации и романтических состояний нет; ванильный каталог без +- [x] Без пака в create ориентации и романтических состояний нет; ванильный каталог без романтических тем -- [ ] Симпатия учеников: ±2 года или соседняя параллель -- [ ] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда; +- [x] Симпатия учеников: ±2 года или соседняя параллель +- [x] Симпатия ученик → учитель можно, видна на обеих карточках; учитель → ученик никогда; осадить — разговор; пары сотрудник↔ученик нет -- [ ] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня -- [ ] Сотрудники между собой — симпатия и пара можно -- [ ] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык -- [ ] Тесты хоста: школа с паком и без; запрет учительской взаимности -- [ ] `example` не трогать +- [x] Пара: оба 18+, взаимно, одна; разрыв — событие, новая не раньше следующего дня +- [x] Сотрудники между собой — симпатия и пара можно +- [x] Никаких сексуальных действий и текстов. Детская линия — дразнилки и ярлык +- [x] Тесты хоста: школа с паком и без; запрет учительской взаимности +- [x] `example` не трогать ## Тесты, без которых фаза не закрыта -- [ ] `GET /api/catalog` без `romance` не отдаёт романтические темы -- [ ] Create с паком пишет ориентацию; без пака поля нет -- [ ] Двое 16-летних не получают статус пары при любом мнении -- [ ] Учитель не получает романтическое состояние на ученика -- [ ] Первоклассник не получает симпатию к одиннадцатому (возраст) -- [ ] Школа с `example` и без `romance` ведёт себя как ваниль в части тем +- [x] `GET /api/catalog` без `romance` не отдаёт романтические темы +- [x] Create с паком пишет ориентацию; без пака поля нет +- [x] Двое 16-летних не получают статус пары при любом мнении +- [x] Учитель не получает романтическое состояние на ученика +- [x] Первоклассник не получает симпатию к одиннадцатому (возраст) +- [x] Школа с `example` и без `romance` ведёт себя как ваниль в части тем ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 392d8e0..b0dfd1e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 -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` @@ -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 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 Russian. @@ -328,7 +335,10 @@ not happen on this request. `connections` is the **Связи** tab: family links with `opinion` / `opinionLabel` from catalog bands, plus `friends`, `enemies` and `others` (every non-family non-zero pair for this person only). -The client does not compute thresholds. There is no school-wide opinions endpoint. +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 { diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 5c63cc9..429f381 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -206,6 +206,10 @@ const ru = { peopleConnectionsEnemies: 'Враги', peopleConnectionsSearch: 'Поиск по связям', peopleConnectionsEmpty: 'Нет других связей.', + peopleConnectionsCrushes: 'Симпатии', + peopleConnectionsAdmirers: 'Нравятся этому человеку', + peopleConnectionsPair: 'Пара', + peopleOrientation: 'Ориентация', peopleOpinionValue: '{label} ({value})', peopleAtHome: 'дома', peopleApparelFit: 'Уместность: {value}', @@ -563,6 +567,10 @@ const en: Messages = { peopleConnectionsEnemies: 'Enemies', peopleConnectionsSearch: 'Search connections', peopleConnectionsEmpty: 'No other connections.', + peopleConnectionsCrushes: 'Crushes', + peopleConnectionsAdmirers: 'Admirers', + peopleConnectionsPair: 'Couple', + peopleOrientation: 'Orientation', peopleOpinionValue: '{label} ({value})', peopleAtHome: 'home', peopleApparelFit: 'Fit: {value}', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index aa7cbd4..bda59ae 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -181,6 +181,12 @@ export interface MapLayout { links: { a: string; b: string }[]; } +export interface TopicInfo { + readonly defName: string; + readonly label: string; + readonly tags: readonly string[]; +} + export interface CatalogResponse { readonly territories: readonly DefInfo[]; readonly buildings: readonly DefInfo[]; @@ -192,6 +198,8 @@ export interface CatalogResponse { readonly subjects: readonly SubjectInfo[]; readonly dayFrame: DayFrameInfo | null; 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 { @@ -301,6 +309,9 @@ export interface PersonConnections { readonly friends: readonly PersonOpinionLink[]; readonly enemies: readonly PersonOpinionLink[]; readonly others: readonly PersonOpinionLink[]; + readonly crushes?: readonly PersonOpinionLink[]; + readonly admirers?: readonly PersonOpinionLink[]; + readonly pair?: PersonRel | null; } export interface PersonCard { @@ -341,6 +352,7 @@ export interface PersonCard { readonly hasFullBody: boolean; readonly customPortraitPrompt: string | null; readonly connections: PersonConnections | null; + readonly orientation?: DefLabel | null; } export interface WornItem { diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 81517ad..327f85d 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -291,6 +291,35 @@ describe('renderPersonCard', () => { 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', () => { setLocale('ru'); const onLogPage = vi.fn(); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 0d62dae..64acf6e 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -266,6 +266,12 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: 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')); appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative); appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative); @@ -275,6 +281,17 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: 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('peopleConnectionsEnemies'), connections.enemies, onRelative); diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index f20ce51..d53921b 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -316,6 +316,8 @@ public sealed class CatalogLoader var behavior = new Dictionary(StringComparer.Ordinal); var colors = new Dictionary(StringComparer.Ordinal); var topics = new Dictionary(StringComparer.Ordinal); + var orientations = new Dictionary(StringComparer.Ordinal); + var affinity = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -384,6 +386,12 @@ public sealed class CatalogLoader case DefKind.Topic: topics[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Orientation: + orientations[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.AffinityRules: + affinity[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -410,6 +418,8 @@ public sealed class CatalogLoader behavior, colors, topics, + orientations, + affinity, ru, en); } @@ -558,7 +568,10 @@ public sealed class CatalogLoader .Concat(Enumerate(catalog.DayFrames.Values)) .Concat(Enumerate(catalog.Holidays.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 Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 12cfb73..e239c61 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -29,6 +29,8 @@ public sealed class DefCatalog IReadOnlyDictionary behavior, IReadOnlyDictionary colors, IReadOnlyDictionary topics, + IReadOnlyDictionary orientations, + IReadOnlyDictionary affinity, IReadOnlyDictionary ru, IReadOnlyDictionary en) { @@ -54,6 +56,8 @@ public sealed class DefCatalog Behavior = behavior; Colors = colors; Topics = topics; + Orientations = orientations; + Affinity = affinity; _ru = ru; _en = en; AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); @@ -113,6 +117,10 @@ public sealed class DefCatalog public IReadOnlyDictionary Topics { get; } + public IReadOnlyDictionary Orientations { get; } + + public IReadOnlyDictionary Affinity { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract); @@ -122,6 +130,9 @@ public sealed class DefCatalog /// The one concrete behaviour ruleset, or null when a pack has not defined it. public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract); + /// The one concrete affinity ruleset, or null when no pack defined it (vanilla). + public AffinityRulesDef? AffinityRules => Affinity.Values.FirstOrDefault(def => !def.Abstract); + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _en; @@ -150,6 +161,8 @@ public sealed class DefCatalog DefKind.Behavior => Behavior.GetValueOrDefault(defName), DefKind.Color => Colors.GetValueOrDefault(defName), DefKind.Topic => Topics.GetValueOrDefault(defName), + DefKind.Orientation => Orientations.GetValueOrDefault(defName), + DefKind.AffinityRules => Affinity.GetValueOrDefault(defName), _ => null, }; @@ -231,6 +244,8 @@ public sealed class DefCatalog BehaviorDef => DefKind.Behavior, ColorDef => DefKind.Color, TopicDef => DefKind.Topic, + OrientationDef => DefKind.Orientation, + AffinityRulesDef => DefKind.AffinityRules, _ => throw new ArgumentOutOfRangeException(nameof(def)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index bbbd536..d4d4113 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -23,6 +23,8 @@ public enum DefKind Behavior, Color, Topic, + Orientation, + AffinityRules, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index 390d865..e5c02d2 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -132,6 +132,12 @@ internal static class PackPaths case "topics": kind = DefKind.Topic; return true; + case "orientations": + kind = DefKind.Orientation; + return true; + case "affinity": + kind = DefKind.AffinityRules; + return true; default: kind = default; return false; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index a46c218..b445982 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -70,6 +70,16 @@ internal static class PeopleDefValidator ValidateTopic(topic, catalog); } + foreach (var orientation in catalog.Orientations.Values) + { + ValidateOrientation(orientation); + } + + foreach (var affinity in catalog.Affinity.Values) + { + ValidateAffinityRules(affinity); + } + RequireTalkTopics(catalog); 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."); } + if (catalog.Affinity.Values.Count(def => !def.Abstract) > 1) + { + throw new ContentLoadException("A catalog may only have one concrete AffinityRulesDef."); + } + 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; + /// A catalog with circle actions but no topics would silently solo-chat — refuse load. private static void RequireTalkTopics(DefCatalog catalog) { diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 9bb03ca..c85ade4 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -210,6 +210,83 @@ public sealed class TraitDef : Def /// Pull toward topics carrying these tags when this person picks the subject. public IReadOnlyList TalkTagWeights { get; init; } = []; + + /// + /// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without + /// affinity rules ignores the field. + /// + public int AffinityThresholdOffset { get; init; } + + /// + /// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still + /// higher (jealous). Ignored without affinity rules. + /// + public int AffinityBreakOffset { get; init; } +} + +/// +/// 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. +/// +public sealed class OrientationDef : Def +{ + public int Weight { get; init; } = 1; + + /// Attracted to people of the same gender. + public bool SameGender { get; init; } + + /// Attracted to people of the opposite gender. + public bool OppositeGender { get; init; } +} + +/// +/// One directed role pairing the affinity overlay may use. Empty +/// means the overlay never fires, even if orientations exist. +/// +public sealed class AffinityRolePair +{ + /// id of the person who holds the feeling. + public required string From { get; init; } + + /// id of the person it is about. + public required string To { get; init; } + + public bool Sympathy { get; init; } + + public bool Pair { get; init; } +} + +/// +/// Optional singleton, like . 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. +/// +public sealed class AffinityRulesDef : Def +{ + /// Opinion at or above this (plus trait offset) becomes a one-way crush. + public int SympathyThreshold { get; init; } = 50; + + /// Mutual crushes at or above this may become a pair, if the role pair allows it. + public int PairThreshold { get; init; } = 70; + + /// A pair ends when either opinion falls below this (plus jealous offset). + public int PairBreakThreshold { get; init; } = 20; + + /// Both people must be at least this old to pair. Sympathy has its own age filter. + public int PairMinAge { get; init; } = 18; + + /// Student–student sympathy: age gap no larger than this, or adjacent year. + public int StudentAgeDeltaYears { get; init; } = 2; + + public bool StudentAdjacentYear { get; init; } = true; + + /// Family members never get a crush or pair. Kinship is not this overlay. + public bool ExcludeFamily { get; init; } = true; + + /// Opinion the rebuffed person loses when a one-way crush is turned down in talk. + public int RebuffOpinionShift { get; init; } = -8; + + public IReadOnlyList RolePairs { get; init; } = []; } /// Conversation subject. Tags gate appropriateness; language is optional skill help. diff --git a/src/HSchool.People/Affinity.cs b/src/HSchool.People/Affinity.cs new file mode 100644 index 0000000..966c98d --- /dev/null +++ b/src/HSchool.People/Affinity.cs @@ -0,0 +1,494 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// One overlay change the simulation can log. Type is a person-log id, not a pack name. +public sealed record AffinityEvent(string PersonId, string Type, string? OtherId); + +/// +/// Crush and pair overlay driven by . No-ops when the catalog has +/// no rules or no orientations — packs are not named here. +/// +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 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(); + 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 RebuffTalk( + DefCatalog catalog, + Roster roster, + IReadOnlyList 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(); + 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 people, + IReadOnlyDictionary years, + Person person, + DateTime asOf, + List events) + { + var bonds = EnsureBonds(person); + if (bonds is null) + { + return; + } + + var keep = new List(); + 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 people, + IReadOnlyDictionary years, + Person person, + DateTime asOf, + List 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 people, + IReadOnlyDictionary years, + Person person, + DateTime asOf, + List 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 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 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 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 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 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; + } + } +} diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs index b7c6d7a..91f3181 100644 --- a/src/HSchool.People/ApplicantPool.cs +++ b/src/HSchool.People/ApplicantPool.cs @@ -179,7 +179,10 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList diff --git a/src/HSchool.People/OrientationGenerator.cs b/src/HSchool.People/OrientationGenerator.cs new file mode 100644 index 0000000..07fcd5c --- /dev/null +++ b/src/HSchool.People/OrientationGenerator.cs @@ -0,0 +1,115 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// +/// Writes only when the catalog has orientations. Vanilla +/// catalogs skip this, so the field stays null and is omitted from people.json. +/// +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; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 9b58c68..46f3c9b 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -55,9 +55,30 @@ public sealed record Person /// What this person thinks of others, A→B. Sparse; zero is not stored. public IReadOnlyDictionary Opinions { get; init; } = new Dictionary(StringComparer.Ordinal); + /// + /// OrientationDef name. Null when the catalog has no orientations (vanilla). Omitted from JSON. + /// + public string? Orientation { get; init; } + + /// Crushes and the one pair. Null without an orientation pack. Mutable overlay. + public PersonBonds? Bonds { get; set; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } +/// +/// 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. +/// +public sealed class PersonBonds +{ + public List Crushes { get; set; } = []; + + public string? PartnerId { get; set; } + + public DateTime? PairEndedOn { get; set; } +} + public sealed record PersonName( string Given, string Surname, diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index f9aaebb..6d87c44 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -45,7 +45,9 @@ public static class RosterGenerator var classes = FillClasses(demand.Classes, people); 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; } /// diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 8d5a8cb..ff3c1f6 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -17,6 +17,7 @@ public static class Seed public const int NativeLanguageSalt = 9; public const int ClimatePresetSalt = 10; public const int ApparelSalt = 11; + public const int OrientationSalt = 12; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.People/YearlyIntake.cs b/src/HSchool.People/YearlyIntake.cs index c6c214f..f2345fc 100644 --- a/src/HSchool.People/YearlyIntake.cs +++ b/src/HSchool.People/YearlyIntake.cs @@ -148,7 +148,9 @@ public static class YearlyIntake var people = remainingPeople.Values.ToList(); 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( diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 93d1cf1..808a6ea 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -126,7 +126,8 @@ internal sealed record CatalogResponse( IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, - MapLayout DefaultMap) + MapLayout DefaultMap, + IReadOnlyList Topics) { public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) => new( @@ -139,7 +140,8 @@ internal sealed record CatalogResponse( PlaceableSubjects(catalog, locale), MapDayFrame(catalog, locale), PlaceableHolidays(catalog, locale), - map); + map, + PlaceableTopics(catalog, locale)); private static IReadOnlyList Placeable(IEnumerable defs, DefCatalog catalog, string locale) where T : Def => @@ -227,6 +229,16 @@ internal sealed record CatalogResponse( def.Start, def.End)) .ToArray(); + + private static IReadOnlyList 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); @@ -270,3 +282,5 @@ internal sealed record DayFrameResponse( int LongBreakMinutes); internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End); + +internal sealed record TopicInfoResponse(string DefName, string Label, IReadOnlyList Tags); diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 13d1f65..24376bd 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -82,7 +82,8 @@ internal sealed record PersonCardResponse( bool HasCustom = false, bool HasFullBody = false, string? CustomPortraitPrompt = null, - PersonConnectionsResponse? Connections = null); + PersonConnectionsResponse? Connections = null, + DefLabelResponse? Orientation = null); internal sealed record WornItemResponse( string DefName, @@ -131,7 +132,10 @@ internal sealed record PersonConnectionsResponse( PersonFamilyResponse Family, IReadOnlyList Friends, IReadOnlyList Enemies, - IReadOnlyList Others); + IReadOnlyList Others, + IReadOnlyList? Crushes = null, + IReadOnlyList? Admirers = null, + PersonRelResponse? Pair = null); internal sealed record DirectoryResponse(IReadOnlyList People); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index c6568c9..303ef49 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -89,7 +89,8 @@ internal static class PersonCardReader person.LockerRoomId is not null || person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)), - Connections: Connections(roster, person, catalog, locale)); + Connections: Connections(roster, person, catalog, locale), + Orientation: OrientationOf(person, catalog, locale)); } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) @@ -449,7 +450,101 @@ internal static class PersonCardReader var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5; var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray(); var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray(); - return new PersonConnectionsResponse(family, friends, enemies, others); + 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 CrushesOf( + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + if (person.Bonds is null) + { + return []; + } + + var rows = new List(); + 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 AdmirersOf( + Roster roster, + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + var rows = new List(); + 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 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( diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 41c77c3..65d9829 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -996,6 +996,17 @@ internal sealed class SchoolWorker 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); if (!RosterFit.Matches(roster, demand)) diff --git a/src/HSchool.Server/mods/romance/README.md b/src/HSchool.Server/mods/romance/README.md new file mode 100644 index 0000000..e798182 --- /dev/null +++ b/src/HSchool.Server/mods/romance/README.md @@ -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. diff --git a/src/HSchool.Server/mods/romance/defs/affinity/rules.jsonc b/src/HSchool.Server/mods/romance/defs/affinity/rules.jsonc new file mode 100644 index 0000000..e4d3f49 --- /dev/null +++ b/src/HSchool.Server/mods/romance/defs/affinity/rules.jsonc @@ -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 }, + ], +} diff --git a/src/HSchool.Server/mods/romance/defs/orientations/orientations.jsonc b/src/HSchool.Server/mods/romance/defs/orientations/orientations.jsonc new file mode 100644 index 0000000..ac55faf --- /dev/null +++ b/src/HSchool.Server/mods/romance/defs/orientations/orientations.jsonc @@ -0,0 +1,18 @@ +[ + { + "defName": "Heterosexual", + "weight": 70, + "oppositeGender": true, + }, + { + "defName": "Bisexual", + "weight": 20, + "sameGender": true, + "oppositeGender": true, + }, + { + "defName": "Homosexual", + "weight": 10, + "sameGender": true, + }, +] diff --git a/src/HSchool.Server/mods/romance/defs/topics/romance.jsonc b/src/HSchool.Server/mods/romance/defs/topics/romance.jsonc new file mode 100644 index 0000000..3f6bf5c --- /dev/null +++ b/src/HSchool.Server/mods/romance/defs/topics/romance.jsonc @@ -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, + }, +] diff --git a/src/HSchool.Server/mods/romance/defs/traits/traits.jsonc b/src/HSchool.Server/mods/romance/defs/traits/traits.jsonc new file mode 100644 index 0000000..9c2c16d --- /dev/null +++ b/src/HSchool.Server/mods/romance/defs/traits/traits.jsonc @@ -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, + }, +] diff --git a/src/HSchool.Server/mods/romance/localizations/en.jsonc b/src/HSchool.Server/mods/romance/localizations/en.jsonc new file mode 100644 index 0000000..4c27440 --- /dev/null +++ b/src/HSchool.Server/mods/romance/localizations/en.jsonc @@ -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", +} diff --git a/src/HSchool.Server/mods/romance/localizations/ru.jsonc b/src/HSchool.Server/mods/romance/localizations/ru.jsonc new file mode 100644 index 0000000..e5ceec3 --- /dev/null +++ b/src/HSchool.Server/mods/romance/localizations/ru.jsonc @@ -0,0 +1,17 @@ +{ + "romance": "Романтика", + "Heterosexual": "гетеро", + "Bisexual": "би", + "Homosexual": "гомо", + "AffinityRules": "Правила симпатии", + "CrushProne": "влюбчивый", + "Jealous": "ревнивый", + "TopicCrush": "кто кому нравится", + "TopicCouple": "пары", + "TopicTease": "дразнилки", + "AffinityCrush": "появилась симпатия", + "AffinityPair": "стали парой", + "AffinityPairBreak": "пара распалась", + "AffinityTease": "дразнилки в классе", + "AffinityRebuff": "разговор-отказ", +} diff --git a/src/HSchool.Server/mods/romance/pack.jsonc b/src/HSchool.Server/mods/romance/pack.jsonc new file mode 100644 index 0000000..3c6142e --- /dev/null +++ b/src/HSchool.Server/mods/romance/pack.jsonc @@ -0,0 +1,4 @@ +{ + "version": "1.0", + "requires": ["core"], +} diff --git a/src/HSchool.Server/mods/romance/patches/topic-appearance.jsonc b/src/HSchool.Server/mods/romance/patches/topic-appearance.jsonc new file mode 100644 index 0000000..4ad51a6 --- /dev/null +++ b/src/HSchool.Server/mods/romance/patches/topic-appearance.jsonc @@ -0,0 +1,6 @@ +{ + "target": "TopicAppearance", + "ops": [ + { "op": "add", "path": "/tags/-", "value": "romance" }, + ], +} diff --git a/src/HSchool.Simulation/AffinitySystem.cs b/src/HSchool.Simulation/AffinitySystem.cs new file mode 100644 index 0000000..5c4dcc3 --- /dev/null +++ b/src/HSchool.Simulation/AffinitySystem.cs @@ -0,0 +1,36 @@ +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// Applies the catalog's affinity overlay after talk and morning opinion shifts. +/// No-ops when the catalog has no affinity rules. +/// +internal static class AffinitySystem +{ + public static bool Apply(School school, IReadOnlyList? justTalked = null) + { + if (school.Catalog is null || school.Roster is null) + { + return false; + } + + var events = new List(); + 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; + } +} diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 753fe4f..848af1f 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -31,7 +31,8 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, ArgumentNullException.ThrowIfNull(catalog); 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)) @@ -85,8 +86,35 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, 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; } + + 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); diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 65f2d9a..19e0080 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -360,7 +360,8 @@ public sealed class School : IDisposable 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); } @@ -376,6 +377,7 @@ public sealed class School : IDisposable PresenceSystem.DrainDecisions(this); LessonLearningSystem.Apply(this, gameMinutes); peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes)); + peopleChanged |= AffinitySystem.Apply(this, talked); } return peopleChanged; diff --git a/tests/HSchool.AppHost.Tests/RomancePackTests.cs b/tests/HSchool.AppHost.Tests/RomancePackTests.cs new file mode 100644 index 0000000..7c3f029 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/RomancePackTests.cs @@ -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("/api/catalog?lang=ru", TestContext.Current.CancellationToken); + var example = await client.GetFromJsonAsync( + "/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( + "/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( + $"/api/schools/{packed.Id}/people?role=student&pageSize=5", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + var card = await client.GetFromJsonAsync( + $"/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( + $"/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( + $"/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( + $"/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( + $"/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() == applicant.Id); + var crushes = teacher!["bonds"]?["crushes"]?.AsArray() ?? []; + Assert.DoesNotContain(crushes, node => node?.GetValue() == pupilId); + + var card = await client.GetFromJsonAsync( + $"/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( + $"/api/schools/{school.Id}/people?role=student&year=1&pageSize=5", + TestContext.Current.CancellationToken); + var eleventh = await client.GetFromJsonAsync( + $"/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( + $"/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 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(TestContext.Current.CancellationToken); + Assert.NotNull(created); + return created; + } + + private static async Task 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 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() == 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 Topics); + + private sealed record TopicInfo(string DefName, string Label, IReadOnlyList Tags); + + private sealed record PeoplePage(IReadOnlyList 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? Crushes, + PersonLink? Pair); + + private sealed record PersonLink(string Id); + + private sealed record StaffingResponse(IReadOnlyList Applicants); + + private sealed record ApplicantRow(string Id, bool IsParent); +} diff --git a/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj b/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj index 0efb927..7d2c6ce 100644 --- a/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj +++ b/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj @@ -29,6 +29,10 @@ example\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest + + romance\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + diff --git a/tests/HSchool.Content.Tests/RomancePackTests.cs b/tests/HSchool.Content.Tests/RomancePackTests.cs new file mode 100644 index 0000000..cf3841c --- /dev/null +++ b/tests/HSchool.Content.Tests/RomancePackTests.cs @@ -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"); +} diff --git a/tests/HSchool.People.Tests/AffinityTests.cs b/tests/HSchool.People.Tests/AffinityTests.cs new file mode 100644 index 0000000..0cd22ed --- /dev/null +++ b/tests/HSchool.People.Tests/AffinityTests.cs @@ -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(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + Orientation = orientation, + Bonds = new PersonBonds(), + }; + } +} diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs index b89c78e..9646a15 100644 --- a/tests/HSchool.People.Tests/Fixtures.cs +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -35,6 +35,19 @@ internal static class Fixtures 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) { var rooms = new RoomNode[count]; diff --git a/tests/HSchool.People.Tests/HSchool.People.Tests.csproj b/tests/HSchool.People.Tests/HSchool.People.Tests.csproj index 4bef833..fbf10f2 100644 --- a/tests/HSchool.People.Tests/HSchool.People.Tests.csproj +++ b/tests/HSchool.People.Tests/HSchool.People.Tests.csproj @@ -30,6 +30,10 @@ example\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest + + romance\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest diff --git a/tests/HSchool.People.Tests/OrientationGeneratorTests.cs b/tests/HSchool.People.Tests/OrientationGeneratorTests.cs new file mode 100644 index 0000000..bc4086e --- /dev/null +++ b/tests/HSchool.People.Tests/OrientationGeneratorTests.cs @@ -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))); + } +} From 72e48914538c734f1dbbf8c89aca1f552eb0713c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:43:20 +0300 Subject: [PATCH 2/2] Mark phase 47 romance pack as complete. --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index a5f4c21..881f315 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -221,7 +221,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [46. Политика речи и дом](46-speech-home.md) | ⬜ | Комбобоксы речи с завтра; утреннее семейное событие | -| [47. Пак romance](47-romance-pack.md) | ⬜ | Ориентация, симпатия, пара 18+, патч тем | +| [47. Пак romance](47-romance-pack.md) | ✅ | Ориентация, симпатия, пара 18+, патч тем | 46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать.