Add opinions storage, morning drift, and Connections card tab.

This commit is contained in:
Leonid Pershin
2026-08-20 07:34:57 +03:00
parent b9d91d387e
commit b1b4f18b40
24 changed files with 1020 additions and 27 deletions
+97
View File
@@ -0,0 +1,97 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Seeds family opinions at generation or when an old save lacks them.</summary>
public static class OpinionGenerator
{
public static Roster SeedFamily(DefCatalog catalog, Roster roster)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(roster);
var rules = catalog.BehaviorRules;
if (rules is null)
{
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 = SeedPersonFamilyOpinions(roster, rules, roster.People, person);
people[i] = updated;
changed |= !ReferenceEquals(updated, person);
}
return changed ? roster with { People = people } : roster;
}
public static bool NeedsFamilyOpinions(Roster roster)
{
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
foreach (var family in roster.Families)
{
foreach (var childId in family.ChildIds)
{
if (!people.TryGetValue(childId, out var child))
{
continue;
}
foreach (var parentId in family.ParentIds)
{
if (!child.Opinions.ContainsKey(parentId))
{
return true;
}
}
}
}
return false;
}
public static Person EnsureMutableOpinions(Person person)
{
if (person.Opinions is Dictionary<string, int>)
{
return person;
}
return person with
{
Opinions = new Dictionary<string, int>(person.Opinions, StringComparer.Ordinal),
};
}
private static Person SeedPersonFamilyOpinions(
Roster roster,
BehaviorDef rules,
IReadOnlyList<Person> rosterPeople,
Person person)
{
var mutable = EnsureMutableOpinions(person);
var changed = !ReferenceEquals(mutable, person);
var peopleById = rosterPeople.ToDictionary(row => row.Id, StringComparer.Ordinal);
foreach (var targetId in OpinionStore.FamilyMemberIds(roster, person))
{
if (!peopleById.TryGetValue(targetId, out var target))
{
continue;
}
var basis = OpinionStore.FamilyBasis(roster, rules, mutable, target);
if (basis == 0 || mutable.Opinions.ContainsKey(targetId))
{
continue;
}
changed |= OpinionStore.Set(mutable, targetId, basis);
}
return changed ? mutable : person;
}
}
+30
View File
@@ -0,0 +1,30 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Threshold captions live in the catalog. The client draws what it is told.</summary>
public static class OpinionLabels
{
public const int MinValue = -100;
public const int MaxValue = 100;
public static string BandId(BehaviorDef? rules, int value)
{
var bands = rules?.OpinionBands is { Count: > 0 } listed
? listed
: BehaviorDef.DefaultOpinionBands;
OpinionBand? best = null;
foreach (var band in bands)
{
if (value >= band.Min && (best is null || band.Min > best.Min))
{
best = band;
}
}
return best?.Id ?? "OpinionAcquaintance";
}
public static string Label(DefCatalog catalog, string locale, int value) =>
catalog.Text(locale, BandId(catalog.BehaviorRules, value));
}
+147
View File
@@ -0,0 +1,147 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>Sparse A→B opinions on a person. Zero is not stored.</summary>
public static class OpinionStore
{
public static int? Get(Person person, string targetId) =>
person.Opinions.TryGetValue(targetId, out var value) ? value : null;
public static bool Set(Person person, string targetId, int value)
{
value = Math.Clamp(value, OpinionLabels.MinValue, OpinionLabels.MaxValue);
if (value == 0)
{
return Remove(person, targetId);
}
if (person.Opinions is not IDictionary<string, int> dict)
{
throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}.");
}
if (dict.IsReadOnly)
{
throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}.");
}
if (dict.TryGetValue(targetId, out var existing) && existing == value)
{
return false;
}
dict[targetId] = value;
return true;
}
public static bool Remove(Person person, string targetId)
{
if (person.Opinions is not IDictionary<string, int> dict)
{
throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}.");
}
if (dict.IsReadOnly)
{
throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}.");
}
return dict.Remove(targetId);
}
public static int FamilyBasis(Roster roster, BehaviorDef rules, Person from, Person to)
{
var family = roster.Families.FirstOrDefault(candidate =>
candidate.Id.Equals(from.FamilyId, StringComparison.Ordinal)
&& candidate.Id.Equals(to.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return 0;
}
var fromParent = InFamily(family.ParentIds, from.Id);
var toParent = InFamily(family.ParentIds, to.Id);
var fromChild = InFamily(family.ChildIds, from.Id);
var toChild = InFamily(family.ChildIds, to.Id);
if (fromParent && toChild)
{
return rules.OpinionParentToChildStart;
}
if (fromChild && toParent)
{
return rules.OpinionChildToParentStart;
}
if (fromChild && toChild && !from.Id.Equals(to.Id, StringComparison.Ordinal))
{
return rules.OpinionSiblingStart;
}
if (fromParent && toParent && !from.Id.Equals(to.Id, StringComparison.Ordinal))
{
return rules.OpinionPartnerStart;
}
return 0;
}
public static int MoveToward(int current, int target, int step)
{
if (current == target)
{
return current;
}
if (current < target)
{
return Math.Min(current + step, target);
}
return Math.Max(current - step, target);
}
public static HashSet<string> FamilyMemberIds(Roster roster, Person person)
{
var family = roster.Families.FirstOrDefault(candidate =>
candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return [];
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var id in family.ParentIds)
{
if (!id.Equals(person.Id, StringComparison.Ordinal))
{
ids.Add(id);
}
}
foreach (var id in family.ChildIds)
{
if (!id.Equals(person.Id, StringComparison.Ordinal))
{
ids.Add(id);
}
}
return ids;
}
private static bool InFamily(IReadOnlyList<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}
+3
View File
@@ -52,6 +52,9 @@ public sealed record Person
/// <summary>Changing-room node when this pupil won a locker slot. Null for staff and the rest.</summary>
public string? LockerRoomId { get; init; }
/// <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);
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
+2 -1
View File
@@ -44,7 +44,8 @@ public static class RosterGenerator
}
var classes = FillClasses(demand.Classes, people);
return LockerAssigner.Apply(catalog, map, new Roster(people, families, classes));
var roster = LockerAssigner.Apply(catalog, map, new Roster(people, families, classes));
return OpinionGenerator.SeedFamily(catalog, roster);
}
/// <summary>