Merge branch 'review/slice-9-ab'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 13:40:20 +03:00
co-authored by Cursor
11 changed files with 338 additions and 2 deletions
+39
View File
@@ -0,0 +1,39 @@
# Журнал ревью — срез 9
## Срез 9. Этапы A–B — мнения и разговоры
- **Фазы:** 4143
- **Проверен на:** `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. Менять ванильный размер кружка ревью не стало.
+1
View File
@@ -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) |
+2 -2
View File
@@ -239,11 +239,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;
+8
View File
@@ -138,6 +138,12 @@ public sealed class School : IDisposable
internal Dictionary<string, ApologyDebt> ApologyDebts { get; } = new(StringComparer.Ordinal);
/// <summary>
/// A finished or interrupted circle wrote opinions onto the roster. <see cref="Tick"/>
/// returns this so the worker persists <c>people.json</c> — same seam as morning dress.
/// </summary>
internal bool RosterTalkDirty { get; set; }
internal void ResetDayLog()
{
_dayLog.Clear();
@@ -400,6 +406,8 @@ public sealed class School : IDisposable
peopleChanged |= AffinitySystem.Apply(this, talked);
}
peopleChanged |= RosterTalkDirty;
RosterTalkDirty = false;
return peopleChanged;
}
@@ -523,6 +523,7 @@ internal static class TalkCircleSystem
}
school.TalkCirclesById.Remove(circle.Id);
school.RosterTalkDirty = true;
}
private static bool TryCatchWhisper(School school, ActiveTalkCircle circle)
+106
View File
@@ -113,6 +113,112 @@ public class TalkCirclesTests
Assert.True(high > low);
}
[Fact]
public void NodeFriendScore_PullsTowardFriends_NotEnemies()
{
var (catalog, _, _) = World();
var opinions = new Dictionary<string, int>(StringComparer.Ordinal)
{
["friend"] = 50,
["enemy"] = -50,
};
var nodes = new Dictionary<string, string>(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<string, int>(StringComparer.Ordinal) { ["enemy"] = -50 };
var nodes = new Dictionary<string, string>(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<string>(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()
{
@@ -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);
}
}
}
@@ -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<string, int>(StringComparer.Ordinal),
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
Traits = [],
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
};
}
}
@@ -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)
@@ -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();
@@ -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()
{