using HSchool.Content; namespace HSchool.People; /// One overlay change the simulation can log. Type is a person-log id, not a pack name. public sealed record AffinityEvent(string PersonId, string Type, string? OtherId); /// /// Crush and pair overlay driven by . No-ops when the catalog has /// no rules or no orientations — packs are not named here. /// public static class Affinity { public const string Crush = "affinity-crush"; public const string Pair = "affinity-pair"; public const string PairBreak = "affinity-pair-break"; public const string Tease = "affinity-tease"; public const string Rebuff = "affinity-rebuff"; public static IReadOnlyList Refresh(DefCatalog catalog, Roster roster, DateTime asOf) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(roster); var rules = catalog.AffinityRules; if (rules is null || catalog.Orientations.Count == 0) { return []; } var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); var events = new List(); var ordered = roster.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray(); foreach (var person in ordered) { DropInvalid(catalog, rules, roster, people, years, person, asOf, events); } foreach (var person in ordered) { GainCrushes(catalog, rules, roster, people, years, person, asOf, events); } foreach (var person in ordered) { TryFormPair(catalog, rules, roster, people, years, person, asOf, events); } return events; } public static IReadOnlyList RebuffTalk( DefCatalog catalog, Roster roster, IReadOnlyList talkedIds, DateTime asOf) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(roster); ArgumentNullException.ThrowIfNull(talkedIds); var rules = catalog.AffinityRules; if (rules is null || catalog.Orientations.Count == 0 || talkedIds.Count < 2) { return []; } var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var present = talkedIds.Where(people.ContainsKey).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray(); var events = new List(); foreach (var fromId in present) { var from = people[fromId]; if (from.Bonds is null) { continue; } foreach (var crushId in from.Bonds.Crushes.ToArray()) { if (!people.TryGetValue(crushId, out var to) || !present.Contains(crushId, StringComparer.Ordinal)) { continue; } if (!from.IsStudent || !to.IsStaff) { continue; } if (RoleAllows(rules, to, from, pair: false)) { continue; } var mutable = OpinionGenerator.EnsureMutableOpinions(from); var current = OpinionStore.Get(mutable, to.Id) ?? 0; OpinionStore.Set(mutable, to.Id, current + rules.RebuffOpinionShift); events.Add(new AffinityEvent(from.Id, Rebuff, to.Id)); } } _ = asOf; return events; } public static bool CanHaveSympathy( DefCatalog catalog, Roster roster, Person from, Person to, DateTime asOf) { var rules = catalog.AffinityRules; if (rules is null || from.Id.Equals(to.Id, StringComparison.Ordinal)) { return false; } var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); return CanSympathy(catalog, rules, roster, years, from, to, asOf); } public static bool CanFormPair( DefCatalog catalog, Roster roster, Person from, Person to, DateTime asOf) { var rules = catalog.AffinityRules; if (rules is null) { return false; } var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); return CanPair(catalog, rules, roster, years, from, to, asOf); } private static void DropInvalid( DefCatalog catalog, AffinityRulesDef rules, Roster roster, Dictionary people, IReadOnlyDictionary years, Person person, DateTime asOf, List events) { var bonds = EnsureBonds(person); if (bonds is null) { return; } var keep = new List(); foreach (var crushId in bonds.Crushes) { if (people.TryGetValue(crushId, out var target) && CanSympathy(catalog, rules, roster, years, person, target, asOf)) { keep.Add(crushId); } } if (keep.Count != bonds.Crushes.Count) { bonds.Crushes.Clear(); bonds.Crushes.AddRange(keep); } if (bonds.PartnerId is null) { return; } if (!people.TryGetValue(bonds.PartnerId, out var partner) || !CanRemainPair(catalog, rules, roster, years, person, partner, asOf)) { var otherId = bonds.PartnerId; BreakPair(person, partner, asOf); events.Add(new AffinityEvent(person.Id, PairBreak, otherId)); if (partner is not null) { events.Add(new AffinityEvent(partner.Id, PairBreak, person.Id)); } } } private static void GainCrushes( DefCatalog catalog, AffinityRulesDef rules, Roster roster, Dictionary people, IReadOnlyDictionary years, Person person, DateTime asOf, List events) { var bonds = EnsureBonds(person); if (bonds is null) { return; } var threshold = rules.SympathyThreshold + TraitOffset(catalog, person, breakOffset: false); foreach (var (targetId, opinion) in person.Opinions) { if (opinion < threshold || !people.TryGetValue(targetId, out var target)) { continue; } if (!CanSympathy(catalog, rules, roster, years, person, target, asOf)) { continue; } if (bonds.Crushes.Contains(targetId, StringComparer.Ordinal)) { continue; } bonds.Crushes.Add(targetId); events.Add(new AffinityEvent(person.Id, Crush, targetId)); if (person.IsStudent) { events.Add(new AffinityEvent(person.Id, Tease, targetId)); } } } private static void TryFormPair( DefCatalog catalog, AffinityRulesDef rules, Roster roster, Dictionary people, IReadOnlyDictionary years, Person person, DateTime asOf, List events) { var bonds = person.Bonds; if (bonds is null || bonds.PartnerId is not null || !CooldownElapsed(bonds, asOf)) { return; } var threshold = rules.PairThreshold; foreach (var crushId in bonds.Crushes.OrderBy(id => id, StringComparer.Ordinal)) { if (!people.TryGetValue(crushId, out var other) || other.Bonds is null) { continue; } if (other.Bonds.PartnerId is not null || !CooldownElapsed(other.Bonds, asOf)) { continue; } if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal)) { continue; } var fromView = OpinionStore.Get(person, other.Id) ?? 0; var toView = OpinionStore.Get(other, person.Id) ?? 0; if (fromView < threshold || toView < threshold) { continue; } if (!CanPair(catalog, rules, roster, years, person, other, asOf)) { continue; } bonds.PartnerId = other.Id; other.Bonds.PartnerId = person.Id; events.Add(new AffinityEvent(person.Id, Pair, other.Id)); events.Add(new AffinityEvent(other.Id, Pair, person.Id)); return; } } private static bool CanSympathy( DefCatalog catalog, AffinityRulesDef rules, Roster roster, IReadOnlyDictionary years, Person from, Person to, DateTime asOf) { if (from.Orientation is null || !Attracted(catalog, from, to)) { return false; } if (rules.ExcludeFamily && OpinionStore.FamilyMemberIds(roster, from).Contains(to.Id)) { return false; } if (!RoleAllows(rules, from, to, pair: false)) { return false; } if (from.IsStudent && to.IsStudent && !StudentsClose(rules, years, from, to, asOf)) { return false; } return true; } private static bool CanPair( DefCatalog catalog, AffinityRulesDef rules, Roster roster, IReadOnlyDictionary years, Person from, Person to, DateTime asOf) { if (!CanSympathy(catalog, rules, roster, years, from, to, asOf) || !CanSympathy(catalog, rules, roster, years, to, from, asOf)) { return false; } if (from.AgeOn(asOf) < rules.PairMinAge || to.AgeOn(asOf) < rules.PairMinAge) { return false; } return RoleAllows(rules, from, to, pair: true) && RoleAllows(rules, to, from, pair: true); } private static bool CanRemainPair( DefCatalog catalog, AffinityRulesDef rules, Roster roster, IReadOnlyDictionary years, Person from, Person to, DateTime asOf) { if (!CanPair(catalog, rules, roster, years, from, to, asOf)) { return false; } var floor = rules.PairBreakThreshold + TraitOffset(catalog, from, breakOffset: true); var view = OpinionStore.Get(from, to.Id) ?? 0; return view >= floor; } private static bool Attracted(DefCatalog catalog, Person from, Person to) { if (from.Orientation is null || !catalog.Orientations.TryGetValue(from.Orientation, out var def) || def.Abstract) { return false; } return from.Female == to.Female ? def.SameGender : def.OppositeGender; } private static bool RoleAllows(AffinityRulesDef rules, Person from, Person to, bool pair) { var fromRole = PrimaryRole(from); var toRole = PrimaryRole(to); if (fromRole is null || toRole is null) { return false; } foreach (var row in rules.RolePairs) { if (!row.From.Equals(fromRole, StringComparison.OrdinalIgnoreCase) || !row.To.Equals(toRole, StringComparison.OrdinalIgnoreCase)) { continue; } return pair ? row.Pair : row.Sympathy; } return false; } private static string? PrimaryRole(Person person) { if (person.IsStudent) { return PersonRoles.Student; } if (person.IsStaff) { return PersonRoles.Staff; } return person.IsParent ? PersonRoles.Parent : null; } private static bool StudentsClose( AffinityRulesDef rules, IReadOnlyDictionary years, Person from, Person to, DateTime asOf) { if (Math.Abs(from.AgeOn(asOf) - to.AgeOn(asOf)) <= rules.StudentAgeDeltaYears) { return true; } if (!rules.StudentAdjacentYear) { return false; } var fromYear = YearOf(years, from); var toYear = YearOf(years, to); return fromYear is { } left && toYear is { } right && Math.Abs(left - right) == 1; } private static int? YearOf(IReadOnlyDictionary years, Person person) => person.ClassId is { } classId && years.TryGetValue(classId, out var schoolClass) ? schoolClass.Year : null; private static int TraitOffset(DefCatalog catalog, Person person, bool breakOffset) { var total = 0; foreach (var traitId in person.Traits) { if (!catalog.Traits.TryGetValue(traitId, out var trait)) { continue; } total += breakOffset ? trait.AffinityBreakOffset : trait.AffinityThresholdOffset; } return total; } private static PersonBonds? EnsureBonds(Person person) { if (person.Orientation is null) { return null; } return person.Bonds ??= new PersonBonds(); } private static bool CooldownElapsed(PersonBonds bonds, DateTime asOf) { if (bonds.PairEndedOn is not { } ended) { return true; } return asOf.Date > DateTime.SpecifyKind(ended, DateTimeKind.Utc).Date; } private static void BreakPair(Person left, Person? right, DateTime asOf) { var leftBonds = EnsureBonds(left); if (leftBonds is not null) { leftBonds.PartnerId = null; leftBonds.PairEndedOn = asOf; } if (right is null) { return; } var rightBonds = EnsureBonds(right); if (rightBonds is not null) { rightBonds.PartnerId = null; rightBonds.PairEndedOn = asOf; } } }