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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user