Refuse enemies joining an open talk circle.

RankInvitees already skipped enemies when a circle formed; a later Chat in the same node still joined without checking opinions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 17:25:36 +03:00
co-authored by Cursor
parent b2b56d7af9
commit a80c6f097a
4 changed files with 135 additions and 13 deletions
+25
View File
@@ -369,6 +369,31 @@ public static class TalkCircles
BehaviorDef? rules) => BehaviorDef? rules) =>
Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max); Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max);
/// <summary>
/// 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.
/// </summary>
public static bool JoinerFits(Person joiner, IReadOnlyList<Person> 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;
}
/// <summary>Invite priority: friends, classmates, others. Enemies never invited.</summary> /// <summary>Invite priority: friends, classmates, others. Enemies never invited.</summary>
public static IReadOnlyList<string> RankInvitees( public static IReadOnlyList<string> RankInvitees(
Person initiator, Person initiator,
+26 -12
View File
@@ -147,10 +147,10 @@ internal static class TalkCircleSystem
return TryStartPhone(school, person, nodeId, action); return TryStartPhone(school, person, nodeId, action);
} }
var existing = FindOpenCircle(school, nodeId, actionId); var existing = FindOpenCircle(school, nodeId, actionId, person);
if (existing is not null) if (existing is not null && JoinCircle(school, existing, personId, action))
{ {
return JoinCircle(school, existing, personId, action); return true;
} }
return TryStartGroup(school, person, nodeId, action); return TryStartGroup(school, person, nodeId, action);
@@ -319,6 +319,14 @@ internal static class TalkCircleSystem
return false; 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.Add(personId);
circle.Members.Sort(StringComparer.Ordinal); circle.Members.Sort(StringComparer.Ordinal);
school.TalkCircleByPerson[personId] = circle.Id; school.TalkCircleByPerson[personId] = circle.Id;
@@ -326,22 +334,28 @@ internal static class TalkCircleSystem
return true; return true;
} }
private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId) private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId, Person joiner)
{
foreach (var circle in school.TalkCirclesById.Values)
{
if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
&& circle.ActionId.Equals(actionId, StringComparison.Ordinal)
&& circle.RemainingMinutes > 0)
{ {
var rules = school.Catalog!.BehaviorRules; var rules = school.Catalog!.BehaviorRules;
var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog); var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
if (circle.Members.Count < max) foreach (var circle in school.TalkCirclesById.Values)
{
if (!circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
|| !circle.ActionId.Equals(actionId, StringComparison.Ordinal)
|| circle.RemainingMinutes <= 0
|| circle.Members.Count >= max)
{
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; return circle;
} }
} }
}
return null; return null;
} }
@@ -206,6 +206,15 @@ public class TalkCirclesTests
Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None)); Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None));
Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality)); Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality));
Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color)); 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] [Fact]
@@ -219,6 +228,42 @@ public class TalkCirclesTests
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour); 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<string, int>(StringComparer.Ordinal) { ["c"] = 10 } };
var friend = Person("b", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 50 } };
var enemy = Person("c", []) with { Opinions = new Dictionary<string, int>(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<string, int>(StringComparer.Ordinal) };
var hater = Person("a", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["d"] = -55 } };
Assert.False(TalkCircles.JoinerFits(hated, [hater], rules));
}
[Fact] [Fact]
public void StudyOnlyPolicy_NeverPicksNonStudyTopic() public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
{ {
@@ -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] [Fact]
public void FifthPerson_DoesNotJoinFullCircle() public void FifthPerson_DoesNotJoinFullCircle()
{ {