Complete phase 47 romance pack on generic orientation and affinity hooks.

Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
This commit is contained in:
Leonid Pershin
2026-08-20 10:42:42 +03:00
parent cb869e4977
commit d2911c3daf
43 changed files with 1840 additions and 29 deletions
+8
View File
@@ -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}',
+12
View File
@@ -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<readonly ModInfo[]> {
@@ -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 {
@@ -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();
+17
View File
@@ -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);
+14 -1
View File
@@ -316,6 +316,8 @@ public sealed class CatalogLoader
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
var colors = new Dictionary<string, ColorDef>(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)
{
@@ -384,6 +386,12 @@ public sealed class CatalogLoader
case DefKind.Topic:
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
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,
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<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, ColorDef> colors,
IReadOnlyDictionary<string, TopicDef> topics,
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> 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<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>
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>
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> _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)),
};
+2
View File
@@ -23,6 +23,8 @@ public enum DefKind
Behavior,
Color,
Topic,
Orientation,
AffinityRules,
}
/// <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":
kind = DefKind.Topic;
return true;
case "orientations":
kind = DefKind.Orientation;
return true;
case "affinity":
kind = DefKind.AffinityRules;
return true;
default:
kind = default;
return false;
+69
View File
@@ -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;
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
private static void RequireTalkTopics(DefCatalog catalog)
{
+77
View File
@@ -210,6 +210,83 @@ public sealed class TraitDef : Def
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
/// <summary>
/// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without
/// affinity rules ignores the field.
/// </summary>
public int AffinityThresholdOffset { get; init; }
/// <summary>
/// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still
/// higher (jealous). Ignored without affinity rules.
/// </summary>
public int AffinityBreakOffset { get; init; }
}
/// <summary>
/// Who this person is drawn to. Present only when a pack ships orientations; vanilla catalogs
/// have none, and the generator then writes no orientation field.
/// </summary>
public sealed class OrientationDef : Def
{
public int Weight { get; init; } = 1;
/// <summary>Attracted to people of the same gender.</summary>
public bool SameGender { get; init; }
/// <summary>Attracted to people of the opposite gender.</summary>
public bool OppositeGender { get; init; }
}
/// <summary>
/// One directed role pairing the affinity overlay may use. Empty <see cref="AffinityRulesDef.RolePairs"/>
/// means the overlay never fires, even if orientations exist.
/// </summary>
public sealed class AffinityRolePair
{
/// <summary><see cref="PersonRoles"/> id of the person who holds the feeling.</summary>
public required string From { get; init; }
/// <summary><see cref="PersonRoles"/> id of the person it is about.</summary>
public required string To { get; init; }
public bool Sympathy { get; init; }
public bool Pair { get; init; }
}
/// <summary>
/// Optional singleton, like <see cref="BehaviorDef"/>. Vanilla core does not ship one; a content
/// pack that wants crushes and pairs adds the numbers. Simulation reads the def if present and
/// no-ops when it is missing — packs are not named in code.
/// </summary>
public sealed class AffinityRulesDef : Def
{
/// <summary>Opinion at or above this (plus trait offset) becomes a one-way crush.</summary>
public int SympathyThreshold { get; init; } = 50;
/// <summary>Mutual crushes at or above this may become a pair, if the role pair allows it.</summary>
public int PairThreshold { get; init; } = 70;
/// <summary>A pair ends when either opinion falls below this (plus jealous offset).</summary>
public int PairBreakThreshold { get; init; } = 20;
/// <summary>Both people must be at least this old to pair. Sympathy has its own age filter.</summary>
public int PairMinAge { get; init; } = 18;
/// <summary>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>
+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);
}
return new ApplicantPool(current.Week, nextIndex, applicants);
return OrientationGenerator.AssignPool(
catalog,
new ApplicantPool(current.Week, nextIndex, applicants),
schoolSeed);
}
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>
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);
}
/// <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(
string Given,
string Surname,
+3 -1
View File
@@ -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;
}
/// <summary>
+1
View File
@@ -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;
/// <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);
+3 -1
View File
@@ -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(
+16 -2
View File
@@ -126,7 +126,8 @@ internal sealed record CatalogResponse(
IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
MapLayout DefaultMap)
MapLayout DefaultMap,
IReadOnlyList<TopicInfoResponse> 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<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
where T : Def =>
@@ -227,6 +229,16 @@ internal sealed record CatalogResponse(
def.Start,
def.End))
.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);
@@ -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<string> Tags);
+6 -2
View File
@@ -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<PersonOpinionLinkResponse> Friends,
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);
+97 -2
View File
@@ -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<string, float>? 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<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(
+11
View File
@@ -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))
+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
@@ -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);
+3 -1
View File
@@ -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;