diff --git a/src/HSchool.Ai/TalkCircles.cs b/src/HSchool.Ai/TalkCircles.cs
index fe907e0..909c801 100644
--- a/src/HSchool.Ai/TalkCircles.cs
+++ b/src/HSchool.Ai/TalkCircles.cs
@@ -369,6 +369,31 @@ public static class TalkCircles
BehaviorDef? rules) =>
Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max);
+ ///
+ /// Enemies do not sit in the same circle. RankInvitees only looks at the initiator when
+ /// forming; a later join is a self-invite, so either side already treating the other as an
+ /// enemy is enough to refuse.
+ ///
+ public static bool JoinerFits(Person joiner, IReadOnlyList members, BehaviorDef? rules)
+ {
+ foreach (var member in members)
+ {
+ if (member.Id.Equals(joiner.Id, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ joiner.Opinions.TryGetValue(member.Id, out var fromJoiner);
+ member.Opinions.TryGetValue(joiner.Id, out var fromMember);
+ if (IsEnemy(fromJoiner, rules) || IsEnemy(fromMember, rules))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/// Invite priority: friends, classmates, others. Enemies never invited.
public static IReadOnlyList RankInvitees(
Person initiator,
diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs
index 963bc4f..49b37c0 100644
--- a/src/HSchool.Simulation/TalkCircleSystem.cs
+++ b/src/HSchool.Simulation/TalkCircleSystem.cs
@@ -147,10 +147,10 @@ internal static class TalkCircleSystem
return TryStartPhone(school, person, nodeId, action);
}
- var existing = FindOpenCircle(school, nodeId, actionId);
- if (existing is not null)
+ var existing = FindOpenCircle(school, nodeId, actionId, person);
+ if (existing is not null && JoinCircle(school, existing, personId, action))
{
- return JoinCircle(school, existing, personId, action);
+ return true;
}
return TryStartGroup(school, person, nodeId, action);
@@ -319,6 +319,14 @@ internal static class TalkCircleSystem
return false;
}
+ var sitting = circle.Members
+ .Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
+ .ToArray();
+ if (!TalkCircles.JoinerFits(person, sitting, rules))
+ {
+ return false;
+ }
+
circle.Members.Add(personId);
circle.Members.Sort(StringComparer.Ordinal);
school.TalkCircleByPerson[personId] = circle.Id;
@@ -326,20 +334,26 @@ internal static class TalkCircleSystem
return true;
}
- private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId)
+ private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId, Person joiner)
{
+ var rules = school.Catalog!.BehaviorRules;
+ var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
foreach (var circle in school.TalkCirclesById.Values)
{
- if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
- && circle.ActionId.Equals(actionId, StringComparison.Ordinal)
- && circle.RemainingMinutes > 0)
+ if (!circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
+ || !circle.ActionId.Equals(actionId, StringComparison.Ordinal)
+ || circle.RemainingMinutes <= 0
+ || circle.Members.Count >= max)
{
- var rules = school.Catalog!.BehaviorRules;
- var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
- if (circle.Members.Count < max)
- {
- return circle;
- }
+ continue;
+ }
+
+ var sitting = circle.Members
+ .Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
+ .ToArray();
+ if (TalkCircles.JoinerFits(joiner, sitting, rules))
+ {
+ return circle;
}
}
diff --git a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
index dc474b9..a87204d 100644
--- a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
+++ b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
@@ -206,6 +206,15 @@ public class TalkCirclesTests
Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None));
Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality));
Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color));
+
+ var (catalog, _, _) = World();
+ var rules = catalog.BehaviorRules!;
+ var topic = catalog.Topics["TopicAppearance"];
+ var from = Person("a", ["RussianLanguage"]);
+ var to = Person("b", ["RussianLanguage"]);
+ var compliment = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
+ var mockery = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.Formality, rules, catalog);
+ Assert.True(compliment > mockery);
}
[Fact]
@@ -219,6 +228,42 @@ public class TalkCirclesTests
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour);
}
+ [Fact]
+ public void LessonWhisperFactor_CutsWhisper_LeavesOrdinaryChat()
+ {
+ var (catalog, _, _) = World();
+ var rules = catalog.BehaviorRules!;
+ Assert.True(TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules) < 1f);
+ Assert.Equal(1f, TalkCircles.LessonWhisperFactor(TalkActions.Chat, rules));
+ Assert.Equal(rules.LessonWhisperSkillFactor, TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules));
+ }
+
+ [Fact]
+ public void Outgoing_RaisesMaxSize_WhenTalkCircleMaxIsBelowHardLimit()
+ {
+ var (catalog, _, _) = World();
+ var rules = new BehaviorDef { DefName = "Rules", TalkCircleMin = 2, TalkCircleMax = 3 };
+ Assert.Equal(3, TalkCircles.MaxSize(TalkActions.Chat, rules, [], catalog));
+ Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, rules, ["Outgoing"], catalog));
+ Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, catalog.BehaviorRules, ["Outgoing"], catalog));
+ }
+
+ [Fact]
+ public void Joiner_IsRejectedWhenEitherSideIsEnemy()
+ {
+ var (catalog, _, _) = World();
+ var rules = catalog.BehaviorRules!;
+ var sitting = Person("a", []) with { Opinions = new Dictionary(StringComparer.Ordinal) { ["c"] = 10 } };
+ var friend = Person("b", []) with { Opinions = new Dictionary(StringComparer.Ordinal) { ["a"] = 50 } };
+ var enemy = Person("c", []) with { Opinions = new Dictionary(StringComparer.Ordinal) { ["a"] = -55 } };
+ Assert.True(TalkCircles.JoinerFits(friend, [sitting], rules));
+ Assert.False(TalkCircles.JoinerFits(enemy, [sitting], rules));
+
+ var hated = Person("d", []) with { Opinions = new Dictionary(StringComparer.Ordinal) };
+ var hater = Person("a", []) with { Opinions = new Dictionary(StringComparer.Ordinal) { ["d"] = -55 } };
+ Assert.False(TalkCircles.JoinerFits(hated, [hater], rules));
+ }
+
[Fact]
public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
{
diff --git a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs
index 4a94695..78a6671 100644
--- a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs
+++ b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs
@@ -52,6 +52,44 @@ public class TalkCircleTests
}
}
+ [Fact]
+ public void Enemy_StartsNeighborCircle_InsteadOfJoiningFriends()
+ {
+ var (school, pupils) = FivePupilsOnBreak();
+ using (school)
+ {
+ var first = pupils[0];
+ var second = pupils[1];
+ var enemy = pupils[2];
+ var ally = pupils[3];
+ OpinionStore.Set(first, second.Id, 55);
+ OpinionStore.Set(second, first.Id, 55);
+ OpinionStore.Set(enemy, first.Id, -55);
+ OpinionStore.Set(first, enemy.Id, -55);
+ OpinionStore.Set(enemy, second.Id, -55);
+ OpinionStore.Set(second, enemy.Id, -55);
+ OpinionStore.Set(enemy, ally.Id, 55);
+ OpinionStore.Set(ally, enemy.Id, 55);
+
+ PlaceAt(school, first.Id, "corridor-1");
+ PlaceAt(school, second.Id, "corridor-1");
+ Assert.True(school.TryStartAction(first.Id, TalkActions.Chat));
+ var friends = school.TalkCircleOf(first.Id);
+ Assert.NotNull(friends);
+ Assert.DoesNotContain(enemy.Id, friends.MemberIds);
+
+ PlaceAt(school, enemy.Id, "corridor-1");
+ PlaceAt(school, ally.Id, "corridor-1");
+ Assert.True(school.TryStartAction(enemy.Id, TalkActions.Chat));
+ var neighbor = school.TalkCircleOf(enemy.Id);
+ Assert.NotNull(neighbor);
+ Assert.NotEqual(friends.MemberIds, neighbor.MemberIds);
+ Assert.Contains(ally.Id, neighbor.MemberIds);
+ Assert.DoesNotContain(first.Id, neighbor.MemberIds);
+ Assert.Equal(friends.MemberIds, school.TalkCircleOf(first.Id)?.MemberIds);
+ }
+ }
+
[Fact]
public void FifthPerson_DoesNotJoinFullCircle()
{