Merge branch 'phase/46-speech-home'

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/phases/09-social/README.md
#	src/HSchool.Server/Game/SchoolWorker.cs
#	tests/HSchool.Ai.Tests/TalkCirclesTests.cs
#	tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs
#	tests/HSchool.Simulation.Tests/TalkCircleTests.cs
This commit is contained in:
Leonid Pershin
2026-08-20 14:06:29 +03:00
35 changed files with 910 additions and 36 deletions
+6
View File
@@ -26,6 +26,12 @@ internal static class MorningDress
changed = true;
}
if (isWorkday && school.SpeechRules.HasPending)
{
school.SpeechRules = school.SpeechRules.ApplyPending();
changed = true;
}
var offCampus = OffCampusIds(school);
foreach (var person in school.Roster.People)
{
+89
View File
@@ -1,3 +1,4 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
@@ -25,6 +26,11 @@ internal static class MorningOpinions
changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning);
}
foreach (var person in school.Roster.People)
{
changed |= FamilyMorning(school, peopleById, person, rules);
}
return changed;
}
@@ -63,4 +69,87 @@ internal static class MorningOpinions
return changed;
}
/// <summary>
/// One family topic per person. Parents stay off the map; this only moves the stored opinion.
/// Runs after drift so a parent already at the family basis still visibly shifts.
/// </summary>
private static bool FamilyMorning(
School school,
IReadOnlyDictionary<string, Person> peopleById,
Person person,
BehaviorDef rules)
{
var catalog = school.Catalog!;
var targetId = PickHomePartner(school.Roster!, person, peopleById);
if (targetId is null)
{
return false;
}
var topicId = TalkCircles.PickTaggedTopic(
catalog,
TopicTags.Family,
Seed.Mix(
school.PeopleSeed,
person.Id,
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
Seed.HomeSalt));
if (topicId is null || !catalog.Topics.TryGetValue(topicId, out var topic))
{
return false;
}
var raw = topic.OpinionShift * rules.HomeTalkOpinionScale;
var delta = (int)Math.Round(raw, MidpointRounding.AwayFromZero);
if (delta == 0 && topic.OpinionShift != 0)
{
delta = topic.OpinionShift > 0 ? 1 : -1;
}
if (delta == 0)
{
return false;
}
var current = OpinionStore.Get(person, targetId) ?? 0;
var changed = OpinionStore.Set(person, targetId, current + delta);
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.HomeTalk,
topicId));
return changed;
}
private static string? PickHomePartner(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> peopleById)
{
var family = roster.Families.FirstOrDefault(candidate =>
candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return null;
}
foreach (var id in family.ParentIds.OrderBy(value => value, StringComparer.Ordinal))
{
if (!id.Equals(person.Id, StringComparison.Ordinal) && peopleById.ContainsKey(id))
{
return id;
}
}
foreach (var id in family.ChildIds.OrderBy(value => value, StringComparer.Ordinal))
{
if (!id.Equals(person.Id, StringComparison.Ordinal) && peopleById.ContainsKey(id))
{
return id;
}
}
return null;
}
}
+5 -2
View File
@@ -13,6 +13,7 @@ public static class PersonLogTypes
public const string ActionStarted = "action-started";
public const string ActionEnded = "action-ended";
public const string TalkEnded = "talk-ended";
public const string HomeTalk = "home-talk";
public const string Whispered = "whispered";
public const string TeacherInterrupted = "teacher-interrupted";
public const string Quarreled = "quarreled";
@@ -114,12 +115,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name);
}
if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal))
if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal))
{
var topic = catalog.Topics.TryGetValue(ThingDef ?? "", out var def)
? catalog.Label(locale, def)
: ThingDef ?? Type;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic);
var key = Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal) ? "HomeTalk" : "TalkEnded";
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), topic);
}
var localeKey = TypeToLocaleKey(Type);
+3
View File
@@ -89,6 +89,9 @@ public sealed class School : IDisposable
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
public SchoolDressRules DressRules { get; set; } = new();
/// <summary>Student and staff speech-topic rules. Pending ids apply on the next work morning.</summary>
public SchoolSpeechRules SpeechRules { get; set; } = new();
/// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; }
+13 -5
View File
@@ -11,7 +11,7 @@ internal sealed class ActiveTalkCircle
public required string ActionId { get; init; }
public required string TopicId { get; init; }
public required string TopicId { get; set; }
public required string NodeId { get; init; }
@@ -227,8 +227,12 @@ internal static class TalkCircleSystem
school.Catalog!,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20))
?? school.Catalog!.Topics.Values.First(topic => !topic.Abstract).DefName;
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20),
speechPolicy: school.SpeechRules.For(initiator.IsStudent));
if (topicId is null)
{
return false;
}
var circle = new ActiveTalkCircle
{
@@ -272,7 +276,8 @@ internal static class TalkCircleSystem
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21),
whisperOnLesson: TalkActions.IsWhisper(action.DefName));
whisperOnLesson: TalkActions.IsWhisper(action.DefName),
speechPolicy: school.SpeechRules.For(initiator.IsStudent));
if (TalkActions.IsTeacherTalk(action.DefName))
{
topicId = TalkCircles.PickTeacherTopic(
@@ -281,7 +286,10 @@ internal static class TalkCircleSystem
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt));
}
topicId ??= school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName;
if (topicId is null)
{
return false;
}
var circle = new ActiveTalkCircle
{