Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
495 lines
14 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|