From b38d0bb628438208211027aab5507cece7eb89be Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 13:34:34 +0300 Subject: [PATCH 1/2] Persist talk opinions and pick topics by weight. A finished circle never set Tick's peopleChanged bit, so people.json kept pre-talk numbers until morning drift. PickTopic compared a 0..total roll to a 0..1 cursor, so gossip almost never changed the subject. Co-authored-by: Cursor --- src/HSchool.Ai/TalkCircles.cs | 4 +- src/HSchool.Simulation/School.cs | 8 ++ src/HSchool.Simulation/TalkCircleSystem.cs | 1 + tests/HSchool.Ai.Tests/TalkCirclesTests.cs | 106 ++++++++++++++++++ .../OpinionGeneratorTests.cs | 40 +++++++ .../HSchool.People.Tests/OpinionStoreTests.cs | 56 +++++++++ .../MorningOpinionsTests.cs | 16 +++ .../TalkCircleTests.cs | 22 ++++ .../HSchool.Simulation.Tests/WhisperTests.cs | 47 ++++++++ 9 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 tests/HSchool.People.Tests/OpinionStoreTests.cs diff --git a/src/HSchool.Ai/TalkCircles.cs b/src/HSchool.Ai/TalkCircles.cs index b249432..68ad1eb 100644 --- a/src/HSchool.Ai/TalkCircles.cs +++ b/src/HSchool.Ai/TalkCircles.cs @@ -229,11 +229,11 @@ public static class TalkCircles } var total = candidates.Sum(pair => pair.Weight); - var roll = (seed & int.MaxValue) % Math.Max(1, (int)(total * 1000)) / 1000f; + var roll = Roll01(seed) * total; var cursor = 0f; foreach (var (topic, weight) in candidates.OrderBy(pair => pair.Topic.DefName, StringComparer.Ordinal)) { - cursor += weight / total; + cursor += weight; if (roll <= cursor) { return topic.DefName; diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 447de1d..e3a1c95 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -136,6 +136,12 @@ public sealed class School : IDisposable internal Dictionary TalkCircleByPerson { get; } = new(StringComparer.Ordinal); + /// + /// A finished or interrupted circle wrote opinions onto the roster. + /// returns this so the worker persists people.json — same seam as morning dress. + /// + internal bool RosterTalkDirty { get; set; } + internal void ResetDayLog() { _dayLog.Clear(); @@ -397,6 +403,8 @@ public sealed class School : IDisposable peopleChanged |= AffinitySystem.Apply(this, talked); } + peopleChanged |= RosterTalkDirty; + RosterTalkDirty = false; return peopleChanged; } diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 914f2cd..cabb20f 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -457,6 +457,7 @@ internal static class TalkCircleSystem } school.TalkCirclesById.Remove(circle.Id); + school.RosterTalkDirty = true; } private static bool TryCatchWhisper(School school, ActiveTalkCircle circle) diff --git a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs index ea88aef..dd7a57c 100644 --- a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs +++ b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs @@ -113,6 +113,112 @@ public class TalkCirclesTests Assert.True(high > low); } + [Fact] + public void NodeFriendScore_PullsTowardFriends_NotEnemies() + { + var (catalog, _, _) = World(); + var opinions = new Dictionary(StringComparer.Ordinal) + { + ["friend"] = 50, + ["enemy"] = -50, + }; + var nodes = new Dictionary(StringComparer.Ordinal) + { + ["friend"] = "corridor-a", + ["enemy"] = "corridor-b", + }; + var context = new TalkPlannerContext("self", opinions, nodes, null, false, true, catalog.BehaviorRules); + Assert.True(TalkCircles.NodeFriendScore("corridor-a", "self", context) > 0); + Assert.True(TalkCircles.NodeFriendScore("corridor-b", "self", context) < 0); + Assert.Equal(0f, TalkCircles.NodeFriendScore("corridor-a", "self", context with { FriendPullActive = false })); + } + + [Fact] + public void LunchNodeBlockedByEnemy_OnlyWhenAnotherSeatExists() + { + var (catalog, _, _) = World(); + var opinions = new Dictionary(StringComparer.Ordinal) { ["enemy"] = -50 }; + var nodes = new Dictionary(StringComparer.Ordinal) { ["enemy"] = "canteen-1" }; + var context = new TalkPlannerContext("self", opinions, nodes, null, false, true, catalog.BehaviorRules); + Assert.True(TalkCircles.LunchNodeBlockedByEnemy("canteen-1", "self", context, hasAlternative: true)); + Assert.False(TalkCircles.LunchNodeBlockedByEnemy("canteen-1", "self", context, hasAlternative: false)); + Assert.False(TalkCircles.LunchNodeBlockedByEnemy("canteen-2", "self", context, hasAlternative: true)); + } + + [Fact] + public void GossipAndOutgoing_UseTraitFields_NotHardcodedIdLists() + { + var (catalog, _, _) = World(); + Assert.True(catalog.Traits["Gossip"].TalkOpinionMultiplier > 1f); + Assert.Contains(catalog.Traits["Gossip"].TalkTagWeights, bias => bias.Tag.Equals("gossip", StringComparison.Ordinal)); + Assert.True(catalog.Traits["Outgoing"].TalkInitiative > 1f); + Assert.Equal(1, catalog.Traits["Outgoing"].TalkCircleBonus); + Assert.True(TalkCircles.OpinionMultiplier(["Gossip"], catalog) > TalkCircles.OpinionMultiplier([], catalog)); + Assert.True(TalkCircles.Initiative(["Outgoing"], catalog) > TalkCircles.Initiative([], catalog)); + } + + [Fact] + public void GossipTrait_BiasesPickTopicTowardGossipTag() + { + var (catalog, _, _) = World(); + var gossip = Person("g", ["RussianLanguage"]) with { Traits = ["Gossip"] }; + var plain = Person("p", ["RussianLanguage"]); + var gossipHits = 0; + var plainHits = 0; + for (var i = 0; i < 200; i++) + { + var seed = Seed.Mix(i, 17, Seed.ApparelSalt); + var gossipId = TalkCircles.PickTopic(catalog, gossip, age: 14, seed); + var plainId = TalkCircles.PickTopic(catalog, plain, age: 14, seed); + if (gossipId is not null && catalog.Topics[gossipId].Tags.Contains("gossip", StringComparer.Ordinal)) + { + gossipHits++; + } + + if (plainId is not null && catalog.Topics[plainId].Tags.Contains("gossip", StringComparer.Ordinal)) + { + plainHits++; + } + } + + Assert.True(gossipHits > plainHits); + } + + [Fact] + public void PickTopic_IsNotAlwaysTheLastNameAlphabetically() + { + var (catalog, _, _) = World(); + var picker = Person("a", ["RussianLanguage"]); + var picked = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < 80; i++) + { + var id = TalkCircles.PickTopic(catalog, picker, age: 14, Seed.Mix(i, 3, Seed.ApparelSalt)); + Assert.NotNull(id); + picked.Add(id!); + } + + Assert.True(picked.Count >= 3); + } + + [Fact] + public void AppearanceTopic_UsesDressAppropriateness() + { + Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None)); + Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality)); + Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color)); + } + + [Fact] + public void CommunicationGain_IsLessThanLessonSkillPerHour() + { + var (catalog, _, _) = World(); + var rules = catalog.BehaviorRules!; + Assert.True(rules.TalkSkillPerHour < rules.LessonSkillPerHour); + var skill = catalog.Skills["Communication"]; + var afterTalkHour = TalkCircles.CommunicationGain(50f, skill, 1f, rules); + Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour); + } + [Fact] public void Enemies_AreNotRankedAsInvitees() { diff --git a/tests/HSchool.People.Tests/OpinionGeneratorTests.cs b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs index 0250069..639ad36 100644 --- a/tests/HSchool.People.Tests/OpinionGeneratorTests.cs +++ b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs @@ -52,4 +52,44 @@ public class OpinionGeneratorTests Assert.True(child.Opinions.TryGetValue(parentId, out var childView)); Assert.NotEqual(parentView, childView); } + + [Fact] + public void RosterJson_RoundTripsFamilyOpinions() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var child = roster.People.First(person => person.IsStudent && !person.IsParent); + var parentId = roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)).ParentIds[0]; + Assert.True(child.Opinions.TryGetValue(parentId, out var before)); + + var loaded = RosterJson.Parse(RosterJson.Serialize(RosterDocument.From(1, roster))).ToRoster(); + var loadedChild = loaded.People.First(person => person.Id.Equals(child.Id, StringComparison.Ordinal)); + Assert.Equal(before, loadedChild.Opinions[parentId]); + } + + [Fact] + public void SaveWithoutOpinionsField_SeedsFamilyOnLoad() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + var node = System.Text.Json.Nodes.JsonNode.Parse(json)!; + foreach (var person in node["people"]!.AsArray()) + { + person!.AsObject().Remove("opinions"); + } + + var stripped = RosterJson.Parse(node.ToJsonString()).ToRoster(); + Assert.True(OpinionGenerator.NeedsFamilyOpinions(stripped)); + Assert.All(stripped.People, person => Assert.Empty(person.Opinions)); + + var seeded = OpinionGenerator.SeedFamily(Fixtures.Catalog(), stripped); + Assert.False(OpinionGenerator.NeedsFamilyOpinions(seeded)); + var child = seeded.People.First(person => person.IsStudent && !person.IsParent); + var family = seeded.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + Assert.NotEmpty(family.ParentIds); + foreach (var parentId in family.ParentIds) + { + Assert.True(child.Opinions.TryGetValue(parentId, out var toParent)); + Assert.True(toParent > 0); + } + } } diff --git a/tests/HSchool.People.Tests/OpinionStoreTests.cs b/tests/HSchool.People.Tests/OpinionStoreTests.cs new file mode 100644 index 0000000..92f98b0 --- /dev/null +++ b/tests/HSchool.People.Tests/OpinionStoreTests.cs @@ -0,0 +1,56 @@ +namespace HSchool.People.Tests; + +public class OpinionStoreTests +{ + [Fact] + public void SetZero_RemovesTheEntry() + { + var person = Blank("a"); + Assert.True(OpinionStore.Set(person, "b", 12)); + Assert.Equal(12, OpinionStore.Get(person, "b")); + + Assert.True(OpinionStore.Set(person, "b", 0)); + Assert.Null(OpinionStore.Get(person, "b")); + Assert.False(person.Opinions.ContainsKey("b")); + } + + [Fact] + public void Set_ClampsToMinusOneHundredAndOneHundred() + { + var person = Blank("a"); + OpinionStore.Set(person, "b", 500); + Assert.Equal(100, person.Opinions["b"]); + OpinionStore.Set(person, "b", -500); + Assert.Equal(-100, person.Opinions["b"]); + } + + private static Person Blank(string id) + { + var cases = new CaseTable + { + Nom = id, + Gen = id, + Dat = id, + Acc = id, + Ins = id, + Pre = id, + }; + return new Person + { + Id = id, + FamilyId = "f", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName(id, id, id, cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs index e306204..5847f7f 100644 --- a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs +++ b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs @@ -63,6 +63,22 @@ public class MorningOpinionsTests Assert.True(after < before); } + [Fact] + public void DriftToZero_DropsTheRecord() + { + using var school = OpenStaffed(); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + var drift = school.Catalog!.BehaviorRules!.OpinionDriftPerMorning; + OpinionStore.Set(child, classmate.Id, Math.Min(2, drift)); + + Assert.True(MorningOpinions.Apply(school)); + Assert.False(child.Opinions.ContainsKey(classmate.Id)); + } + private static void AdvanceTo(School school, DateTime until) { while (school.Clock.Time < until) diff --git a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs index 0d8729f..ea073ad 100644 --- a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs +++ b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs @@ -151,6 +151,28 @@ public class TalkCircleTests } } + [Fact] + public void TalkCircleFinish_MarksTickPeopleChangedSoSaveWritesOpinions() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + OpinionStore.Set(first, second.Id, 10); + PlaceAt(school, first.Id, "corridor-1"); + PlaceAt(school, second.Id, "corridor-1"); + Assert.True(school.TryStartAction(first.Id, TalkActions.Chat)); + + var peopleChanged = false; + while (ActivityOf(school, first.Id) is not null) + { + peopleChanged |= school.Tick(0.2d, 5d); + } + + Assert.True(peopleChanged); + Assert.NotEqual(10, OpinionStore.Get(first, second.Id)); + } + } + private static (School School, Person First, Person Second) TwoPupilsWithPhonesOnBreak() { var (catalog, map) = Vanilla(); diff --git a/tests/HSchool.Simulation.Tests/WhisperTests.cs b/tests/HSchool.Simulation.Tests/WhisperTests.cs index b4ee0a7..f74f49b 100644 --- a/tests/HSchool.Simulation.Tests/WhisperTests.cs +++ b/tests/HSchool.Simulation.Tests/WhisperTests.cs @@ -134,6 +134,53 @@ public class WhisperTests } } + [Fact] + public void CaughtWhisper_BecomesDisciplineTalkWithTeacher() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + AdvanceTo(school, LessonStart); + var converted = false; + for (var i = 0; i < 24; i++) + { + school.Clock.JumpTo(LessonStart.AddMinutes(i)); + TalkCircleSystem.Interrupt(school, firstId); + TalkCircleSystem.Interrupt(school, secondId); + TalkCircleSystem.Interrupt(school, teacherId); + var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Contains(firstId)); + foreach (var id in schoolClass.PupilIds) + { + PlaceAt(school, id, id == firstId || id == secondId ? homeroom : "corridor-1"); + } + + PlaceAt(school, firstId, homeroom); + PlaceAt(school, secondId, homeroom); + PlaceAt(school, teacherId, homeroom); + Assert.True(school.TryStartAction(firstId, TalkActions.Whisper)); + TalkCircleSystem.Apply(school, 0.2d); + if (!school.DayLog.Any(row => + (row.PersonId == firstId || row.PersonId == secondId) + && row.Type.Equals(PersonLogTypes.TeacherInterrupted, StringComparison.Ordinal) + && row.Time == school.Clock.Time)) + { + continue; + } + + Assert.Equal(TalkActions.TeacherTalk, ActivityOf(school, firstId)); + Assert.Equal(TalkActions.TeacherTalk, ActivityOf(school, teacherId)); + var circle = school.TalkCircleOf(firstId); + Assert.NotNull(circle); + Assert.Equal(TeacherTopics.Discipline, circle.TopicId); + Assert.Contains(teacherId, circle.MemberIds); + converted = true; + break; + } + + Assert.True(converted); + } + } + [Fact] public void OrdinaryChat_DoesNotStartOnLesson() { From 26666ba426728a3950fe96b8e0f947695ceef243 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 13:35:16 +0300 Subject: [PATCH 2/2] Record slice 9 A-B review. Phases 41-43 checked on b38d0bb. 44 and 46 were in flight; Outgoing.talkCircleBonus is a no-op at max 4. Co-authored-by: Cursor --- docs/phases/09-social/reviewed.md | 39 +++++++++++++++++++++++++++++++ docs/phases/reviewed.md | 1 + 2 files changed, 40 insertions(+) create mode 100644 docs/phases/09-social/reviewed.md diff --git a/docs/phases/09-social/reviewed.md b/docs/phases/09-social/reviewed.md new file mode 100644 index 0000000..072a4ef --- /dev/null +++ b/docs/phases/09-social/reviewed.md @@ -0,0 +1,39 @@ +# Журнал ревью — срез 9 + +## Срез 9. Этапы A–B — мнения и разговоры + +- **Фазы:** 41–43 +- **Проверен на:** `b38d0bb`, 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/mods/core/defs/topics/`, `src/HSchool.Server/mods/core/defs/traits/`, `src/HSchool.Client/src/ui/personCard.ts`, `docs/protocol.md`, `tests/HSchool.People.Tests`, `tests/HSchool.Ai.Tests`, `tests/HSchool.Simulation.Tests` +- **Итог:** обещания 41–43 на месте; дописано 14 тестов; исправлено 2 расхождения (сейв мнений после разговора; `PickTopic` почти всегда брал последнюю тему). 44 и 46 — **в работе на момент проверки** (44 🔄, 46 ⬜). 45 и 47 в этот проход не входили. +- **Открыто:** `Outgoing.talkCircleBonus` не меняет размер кружка при `talkCircleMax` 4 и жёстком clamp 4. + +### Что проверено + +Каждый пункт «Тесты, без которых фаза не закрыта» у 41–43 имеет отдельный тест. Клиент рисует присланную `opinionLabel`, вкладка «Связи» не монтирует лог и ничего не POST-ит. Сокет 41–43 не бампили. `dotnet test` с `--filter` по затронутым классам зелёный. + +### Что дописано + +- `OpinionStoreTests.SetZero_RemovesTheEntry` +- `OpinionStoreTests.Set_ClampsToMinusOneHundredAndOneHundred` +- `OpinionGeneratorTests.RosterJson_RoundTripsFamilyOpinions` +- `OpinionGeneratorTests.SaveWithoutOpinionsField_SeedsFamilyOnLoad` +- `MorningOpinionsTests.DriftToZero_DropsTheRecord` +- `TalkCirclesTests.NodeFriendScore_PullsTowardFriends_NotEnemies` +- `TalkCirclesTests.LunchNodeBlockedByEnemy_OnlyWhenAnotherSeatExists` +- `TalkCirclesTests.GossipAndOutgoing_UseTraitFields_NotHardcodedIdLists` +- `TalkCirclesTests.GossipTrait_BiasesPickTopicTowardGossipTag` +- `TalkCirclesTests.AppearanceTopic_UsesDressAppropriateness` +- `TalkCirclesTests.CommunicationGain_IsLessThanLessonSkillPerHour` +- `TalkCirclesTests.PickTopic_IsNotAlwaysTheLastNameAlphabetically` +- `TalkCircleTests.TalkCircleFinish_MarksTickPeopleChangedSoSaveWritesOpinions` +- `WhisperTests.CaughtWhisper_BecomesDisciplineTalkWithTeacher` + +### Что исправлено + +- Конец кружка писал мнения в ростер, но `Tick` не возвращал `peopleChanged` — воркер не сохранял `people.json` до утреннего дрейфа. +- `PickTopic` сравнивал бросок в шкале `0…total` с курсором `0…1`, поэтому тема почти всегда была последней по имени, а сплетник не сдвигал выбор. + +### Открытое + +- Черта «общительный» задаёт `talkCircleBonus: 1`, но `MaxSize` клампит к 4 при уже `talkCircleMax` 4 — поле мёртвое, пока верхний потолок не ниже 4. Менять ванильный размер кружка ревью не стало. diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index bb6f8bf..2bc5d0e 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -31,3 +31,4 @@ | Срез 7. Этап A — данные и гардероб | `d5547b7` | [07-inventory](07-inventory/reviewed.md) | | Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) | +| Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) |