Files
h-school/src/HSchool.People/Affinity.cs
T
Leonid Pershin d2911c3daf 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.
2026-08-20 10:42:42 +03:00

495 lines
14 KiB
C#

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;
}
}
}