Merge branch 'phase/47-romance-pack'
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 19s

# Conflicts:
#	docs/phases/README.md
#	src/HSchool.Content/PeopleDefs.cs
#	src/HSchool.People/Seed.cs
This commit is contained in:
Leonid Pershin
2026-08-20 11:00:12 +03:00
44 changed files with 1841 additions and 30 deletions
+7
View File
@@ -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`, потом выбранные моды в том порядке, в каком они стоят в
+15 -15
View File
@@ -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` ведёт себя как ваниль в части тем
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -221,7 +221,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [46. Политика речи и дом](46-speech-home.md) | ⬜ | Комбобоксы речи с завтра; утреннее семейное событие | | [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` не трогать. 46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать.
+12 -2
View File
@@ -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
{ {
+8
View File
@@ -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}',
+12
View File
@@ -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();
+17
View File
@@ -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);
+14 -1
View File
@@ -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);
} }
+15
View File
@@ -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)),
}; };
+2
View File
@@ -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>
+6
View File
@@ -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;
+69
View File
@@ -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);
} }
@@ -645,6 +660,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)
{ {
+77
View File
@@ -215,6 +215,83 @@ public sealed class TraitDef : Def
/// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1. /// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1.
/// </summary> /// </summary>
public float WhisperCatchMultiplier { get; init; } = 1f; public float WhisperCatchMultiplier { get; init; } = 1f;
/// <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>Studentstudent 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>
+494
View File
@@ -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;
}
}
}
+4 -1
View File
@@ -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) =>
+115
View File
@@ -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;
}
}
+21
View File
@@ -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,
+3 -1
View File
@@ -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>
+1
View File
@@ -18,6 +18,7 @@ public static class Seed
public const int ClimatePresetSalt = 10; public const int ClimatePresetSalt = 10;
public const int ApparelSalt = 11; public const int ApparelSalt = 11;
public const int WhisperSalt = 12; public const int WhisperSalt = 12;
public const int OrientationSalt = 13;
/// <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);
+3 -1
View File
@@ -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(
+16 -2
View File
@@ -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);
+6 -2
View File
@@ -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);
+97 -2
View File
@@ -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(
+11
View File
@@ -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))
+28
View File
@@ -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" },
],
}
+36
View File
@@ -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;
}
}
+29 -1
View File
@@ -43,7 +43,8 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
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))
@@ -97,8 +98,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);
+3 -1
View File
@@ -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");
}
+195
View File
@@ -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(),
};
}
}
+13
View File
@@ -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)));
}
}