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
+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(