From a80c6f097a71b10ad20ab5782649b1add00a2cf6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 17:25:36 +0300 Subject: [PATCH 1/2] 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 --- src/HSchool.Ai/TalkCircles.cs | 25 +++++++++++ src/HSchool.Simulation/TalkCircleSystem.cs | 40 +++++++++++------ tests/HSchool.Ai.Tests/TalkCirclesTests.cs | 45 +++++++++++++++++++ .../TalkCircleTests.cs | 38 ++++++++++++++++ 4 files changed, 135 insertions(+), 13 deletions(-) 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() { From 71683d8b40d3983d2b8a8867b74735e8f598b7ea Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 17:26:28 +0300 Subject: [PATCH 2/2] Record slice 9 A-B recheck after later stages drifted talk paths. Co-authored-by: Cursor --- docs/phases/09-social/reviewed.md | 28 ++++++++++++++++++++++++++++ docs/phases/reviewed.md | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/phases/09-social/reviewed.md b/docs/phases/09-social/reviewed.md index e8afc2b..a302f9a 100644 --- a/docs/phases/09-social/reviewed.md +++ b/docs/phases/09-social/reviewed.md @@ -1,5 +1,33 @@ # Журнал ревью — срез 9 +## Срез 9. Этапы A–B — перепроверка мнений и разговоров + +- **Фазы:** 41–43 +- **Проверен на:** `a80c6f0`, 2026-08-20 +- **Пути:** `src/HSchool.People/OpinionStore.cs`, `src/HSchool.People/OpinionGenerator.cs`, `src/HSchool.People/OpinionLabels.cs`, `src/HSchool.Ai/TalkCircles.cs`, `src/HSchool.Simulation/MorningOpinions.cs`, `src/HSchool.Simulation/TalkCircleSystem.cs`, `src/HSchool.Simulation/School.cs`, `src/HSchool.Server/Game/PersonCardReader.cs`, `src/HSchool.Server/Game/PersonCardReader.Connections.cs`, `src/HSchool.Server/mods/core/defs/topics/`, `src/HSchool.Server/mods/core/defs/traits/`, `src/HSchool.Client/src/ui/personCard.ts`, `src/HSchool.Client/src/ui/personCardConnections.ts`, `docs/protocol.md`, `tests/HSchool.People.Tests`, `tests/HSchool.Ai.Tests`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.Content.Tests/TopicDefTests.cs` +- **Итог:** перепроверка после `b38d0bb` (дальше сели 44–47 и сплит карточки). Обещания 41–43 на месте; прошлые правки (сейв кружка, `PickTopic`) живы. Дописано 4 теста; исправлено вступление врага в чужой кружок. `Seed.Mix` в политиковых тестах не трогали. C–D не переписывали. +- **Открыто:** ванильный `talkCircleMax` 4 по-прежнему съедает `Outgoing.talkCircleBonus` (поле работает, если потолок ниже 4). + +### Что проверено + +Каждый пункт «Тесты, без которых фаза не закрыта» у 41–43 имеет отдельный тест. После 58 вкладка «Связи» в `personCardConnections.ts`; клиент рисует присланную `opinionLabel`, лог не монтирует, POST с вкладки нет. Каталог без `TopicDef` падает на загрузке. `RosterTalkDirty` после кружка жив. Сокет 41–43 не бампили (бамп — фаза 45). `dotnet test` с `--filter` по `TalkCirclesTests`, `TalkCircleTests`, `WhisperTests`, `OpinionStoreTests`, `OpinionGeneratorTests`, `MorningOpinionsTests`, `TopicDefTests` зелёный. + +### Что дописано + +- `TalkCirclesTests.Joiner_IsRejectedWhenEitherSideIsEnemy` +- `TalkCirclesTests.LessonWhisperFactor_CutsWhisper_LeavesOrdinaryChat` +- `TalkCirclesTests.Outgoing_RaisesMaxSize_WhenTalkCircleMaxIsBelowHardLimit` +- `TalkCircleTests.Enemy_StartsNeighborCircle_InsteadOfJoiningFriends` +- `TalkCirclesTests.AppearanceTopic_UsesDressAppropriateness` — усилен: `OpinionDelta` на теме внешности, не только хелпер + +### Что исправлено + +- `FindOpenCircle` / `JoinCircle` не смотрели мнения: враг в том же узле вступал в уже открытый кружок друзей, хотя старт кружка врагов не зовёт. Теперь отказ и соседний кружок, если есть с кем. + +### Открытое + +- Черта «общительный» задаёт `talkCircleBonus: 1`, но ванильный `talkCircleMax` уже 4, а жёсткий потолок тоже 4. Код бонус считает (тест с max 3), в ванили поле по-прежнему не двигает размер. Менять ванильный потолок ревью не стало. + ## Срез 9. Этапы A–B — мнения и разговоры - **Фазы:** 41–43 diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 2be9336..6e70544 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -29,7 +29,7 @@ | Срез 7. Этап A — данные и гардероб | `5468852` | [07-inventory](07-inventory/reviewed.md) | | Срез 7. Этап B–C — климат, жизнь одежды и правила | `549634b` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `7100065` | [08-session](08-session/reviewed.md) | -| Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) | +| Срез 9. Этапы A–B — мнения и разговоры | `a80c6f0` | [09-social](09-social/reviewed.md) | | Срез 9. Этапы C–D — конфликт, присутствие, речь и romance | `19554d4` | [09-social](09-social/reviewed.md) | | Срез 10. Этап A — страховка | `e2e7d30` | [10-craft](10-craft/reviewed.md) | | Срез 10. Этапы B–C — работник, карточка и дамп | `f4f726a` | [10-craft](10-craft/reviewed.md) |