From f7d5e0c042cc00403a201d4473386e91cdd534f3 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:35:31 +0300 Subject: [PATCH 1/2] Add lesson whispers, teacher catch chance, and after-bell teacher talk. Whispering cuts lesson skill gain from BehaviorDef; a teacher in the room may interrupt into a discipline circle, and pedagogy scales the pupil's opinion of the teacher. Co-authored-by: Cursor --- docs/design/social.md | 3 + docs/phases/43-whisper.md | 24 +- src/HSchool.Ai/Decision.cs | 31 +- src/HSchool.Ai/TalkCircles.cs | 111 ++++++- src/HSchool.Content/PeopleDefValidator.cs | 43 ++- src/HSchool.Content/PeopleDefs.cs | 36 +++ src/HSchool.Content/TalkActions.cs | 20 +- src/HSchool.People/Seed.cs | 1 + .../mods/core/defs/actions/living.jsonc | 18 ++ .../mods/core/defs/behavior/rules.jsonc | 18 +- .../mods/core/defs/topics/vanilla.jsonc | 24 ++ .../mods/core/defs/traits/traits.jsonc | 3 + .../mods/core/localizations/en.jsonc | 7 + .../mods/core/localizations/ru.jsonc | 7 + .../LessonLearningSystem.cs | 9 +- src/HSchool.Simulation/PersonLog.cs | 12 + src/HSchool.Simulation/PresenceSystem.cs | 45 ++- src/HSchool.Simulation/TalkCircleSystem.cs | 200 +++++++++++- tests/HSchool.Ai.Tests/WhisperTests.cs | 178 +++++++++++ .../HSchool.Content.Tests/BehaviorDefTests.cs | 2 + tests/HSchool.Content.Tests/TopicDefTests.cs | 4 + .../HSchool.Simulation.Tests/WhisperTests.cs | 301 ++++++++++++++++++ 22 files changed, 1061 insertions(+), 36 deletions(-) create mode 100644 tests/HSchool.Ai.Tests/WhisperTests.cs create mode 100644 tests/HSchool.Simulation.Tests/WhisperTests.cs diff --git a/docs/design/social.md b/docs/design/social.md index 99c3886..bf3a31d 100644 --- a/docs/design/social.md +++ b/docs/design/social.md @@ -133,6 +133,9 @@ **педагогикой** учителя: сильный педагог после похвалы плюсует больше, слабый выговор злит сильнее. Оценок по-прежнему нет. +Шёпот на уроке — действие `Whisper` в кабинете (`Classroom`); обычный `Chat` на уроке не +стартует. Доля роста навыка, шанс оборвать и веса разговора после звонка — поля `BehaviorDef`. + ### Дом Родители на карту не приходят. **Одно** семейное событие на человека за рабочее утро; skip diff --git a/docs/phases/43-whisper.md b/docs/phases/43-whisper.md index de00e9e..d09405a 100644 --- a/docs/phases/43-whisper.md +++ b/docs/phases/43-whisper.md @@ -11,22 +11,22 @@ ## Задачи -- [ ] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса -- [ ] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`) -- [ ] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 % -- [ ] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у +- [x] Тема с тегом шёпота может стартовать кружок на уроке в кабинете класса +- [x] У участников на время шёпота режется рост навыка урока (доля в `BehaviorDef`) +- [x] Учитель в том же кабинете: шанс оборвать (данные + черты); не 100 % +- [x] Пойманный шёпот → выговор (кружок учитель + ученики, тема дисциплины), мнения как у строгого разговора -- [ ] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели -- [ ] Сдвиг мнения ученика об учителе масштабируется педагогикой (поле навыка, не фамилия) -- [ ] Лог: «шептались», «учитель оборвал» +- [x] После урока учитель может взять 2–4 учеников (вопрос / похвала / выговор) по весу цели +- [x] Сдвиг мнения ученика об учителе масштабируется педагогикой (поле навыка, не фамилия) +- [x] Лог: «шептались», «учитель оборвал» ## Тесты, без которых фаза не закрыта -- [ ] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка -- [ ] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается -- [ ] Без учителя в узле шёпот не обрывается этим правилом -- [ ] Учитель с высокой педагогикой после похвалы сильнее плюсует мнение, чем с низкой -- [ ] Обычный не-шёпотный `Chat` на уроке не стартует +- [x] Шёпот на уроке даёт меньше прироста предмета, чем тихий урок без кружка +- [x] При фиксированном сиде не каждый шёпот с учителем в кабинете обрывается +- [x] Без учителя в узле шёпот не обрывается этим правилом +- [x] Учитель с высокой педагогикой после похвалы сильнее плюсует мнение, чем с низкой +- [x] Обычный не-шёпотный `Chat` на уроке не стартует ## Критерий готовности diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs index 9c6138e..6cd422d 100644 --- a/src/HSchool.Ai/Decision.cs +++ b/src/HSchool.Ai/Decision.cs @@ -250,7 +250,7 @@ public static class DecisionPlanner OccupiedCount occupied, ActionDef action) { - if (action.Abstract || action.Weight <= 0 || !RoleFits(action, state)) + if (action.Abstract || !RoleFits(action, state)) { return Intent.None; } @@ -260,6 +260,32 @@ public static class DecisionPlanner return Intent.None; } + if (TalkActions.IsTeacherTalk(action.DefName)) + { + if (state.BoundToLesson) + { + return Intent.None; + } + + var teacherWeight = catalog.BehaviorRules?.TeacherAfterLessonWeight ?? 0f; + if (teacherWeight <= 0) + { + return Intent.None; + } + + if (RoomFor(catalog, map, walks, state, occupied, action) is null) + { + return Intent.None; + } + + return new Intent(GoalKind.Leisure, action.DefName, teacherWeight, action.DefName); + } + + if (action.Weight <= 0) + { + return Intent.None; + } + if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal) && !state.Talk.HasPhone) { return Intent.None; @@ -371,7 +397,8 @@ public static class DecisionPlanner { if (action.Abstract || !need.Equals(action.Need, StringComparison.Ordinal) - || !RoleFits(action, state)) + || !RoleFits(action, state) + || (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName))) { continue; } diff --git a/src/HSchool.Ai/TalkCircles.cs b/src/HSchool.Ai/TalkCircles.cs index cf19b74..b249432 100644 --- a/src/HSchool.Ai/TalkCircles.cs +++ b/src/HSchool.Ai/TalkCircles.cs @@ -18,7 +18,8 @@ public readonly record struct TalkPlannerContext( /// public static class TalkCircles { - public static bool BlocksOnLesson(string actionId) => TalkActions.IsTalk(actionId); + public static bool BlocksOnLesson(string actionId) => + TalkActions.IsTalk(actionId) && !TalkActions.IsWhisper(actionId); public static int MaxSize(string actionId, BehaviorDef? rules, IReadOnlyList traitIds, DefCatalog catalog) { @@ -27,6 +28,12 @@ public static class TalkCircles return 2; } + if (actionId.Equals(TalkActions.TeacherTalk, StringComparison.Ordinal)) + { + // Teacher plus 2–4 pupils; the live-circle cap of 4 is the pupil side. + return 1 + Math.Clamp(rules?.TalkCircleMax ?? 4, 2, 4); + } + var max = rules?.TalkCircleMax ?? 4; foreach (var traitId in traitIds) { @@ -182,14 +189,16 @@ public static class TalkCircles DefCatalog catalog, Person picker, int age, - int seed) + int seed, + bool whisperOnLesson = false) { var candidates = new List<(TopicDef Topic, float Weight)>(); foreach (var topic in catalog.Topics.Values) { if (topic.Abstract || !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent) - || !AgeFits(topic, age)) + || !AgeFits(topic, age) + || (whisperOnLesson && !topic.WhisperOnLesson)) { continue; } @@ -368,6 +377,102 @@ public static class TalkCircles .ToArray(); } + /// 0…1 from a mixed seed. Same seed, same roll — never string.GetHashCode. + public static float Roll01(int seed) + { + var unit = (seed & int.MaxValue) / (float)int.MaxValue; + return Math.Clamp(unit, 0f, 1f); + } + + /// Traits multiply: quiet dampens, outgoing raises, missing stays 1. + public static float WhisperCatchMultiplier(IReadOnlyList traitIds, DefCatalog catalog) + { + var factor = 1f; + foreach (var traitId in traitIds) + { + if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.WhisperCatchMultiplier > 0f) + { + factor *= trait.WhisperCatchMultiplier; + } + } + + return factor; + } + + /// + /// True when this roll catches a whisper. keeps it below 100 % + /// even with loud traits. + /// + public static bool WhisperCaught(float roll, float baseChance, float catchMax, float traitMultiplier) + { + var ceiling = Math.Clamp(catchMax, 0f, 0.99f); + var chance = Math.Clamp(baseChance * Math.Max(0f, traitMultiplier), 0f, ceiling); + return chance > 0f && roll < chance; + } + + /// + /// Pupil → teacher opinion after talk. Pedagogy 50 is identity; a strong teacher after praise + /// pluses more, a weak reprimand angers more. + /// + public static int ScaleByPedagogy(int delta, float pedagogy, BehaviorDef? rules) + { + if (delta == 0) + { + return 0; + } + + var t = (Math.Clamp(pedagogy, 0f, 100f) - 50f) / 50f; + var k = rules?.TalkPedagogyOpinionScale ?? 0.4f; + var scale = delta > 0 ? 1f + (t * k) : 1f - (t * k); + var scaled = (int)Math.Round(delta * Math.Max(0.1f, scale), MidpointRounding.AwayFromZero); + if (delta > 0) + { + return Math.Max(1, scaled); + } + + return Math.Min(-1, scaled); + } + + public static string? PickTeacherTopic(DefCatalog catalog, BehaviorDef? rules, int seed) + { + var options = new (string Id, float Weight)[] + { + (TeacherTopics.Question, rules?.TeacherQuestionWeight ?? 2f), + (TeacherTopics.Praise, rules?.TeacherPraiseWeight ?? 2f), + (TeacherTopics.Discipline, rules?.TeacherReprimandWeight ?? 1f), + }; + var available = new List<(string Id, float Weight)>(); + foreach (var option in options) + { + if (option.Weight > 0f && catalog.Topics.ContainsKey(option.Id)) + { + available.Add(option); + } + } + + if (available.Count == 0) + { + return catalog.Topics.Values.FirstOrDefault(topic => !topic.Abstract)?.DefName; + } + + var total = available.Sum(pair => pair.Weight); + var roll = Roll01(seed) * total; + var cursor = 0f; + foreach (var (id, weight) in available.OrderBy(pair => pair.Id, StringComparer.Ordinal)) + { + cursor += weight; + if (roll <= cursor) + { + return id; + } + } + + return available[^1].Id; + } + + public static float LessonWhisperFactor(string? actionId, BehaviorDef? rules) => + TalkActions.IsWhisper(actionId) ? rules?.LessonWhisperSkillFactor ?? 0.4f : 1f; + public readonly record struct Candidate( string Id, string? ClassId, diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index a46c218..cf8e8fa 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -261,6 +261,11 @@ internal static class PeopleDefValidator throw new ContentLoadException($"TraitDef '{trait.DefName}' skill modifier references unknown SkillDef '{modifier.Skill}'."); } } + + if (trait.WhisperCatchMultiplier < 0f) + { + throw new ContentLoadException($"TraitDef '{trait.DefName}' whisperCatchMultiplier cannot be negative."); + } } private static void ValidateNeed(NeedDef need) @@ -574,6 +579,39 @@ internal static class PeopleDefValidator throw new ContentLoadException( $"BehaviorDef '{behavior.DefName}' talk circle size must be at least 2 with max ≥ min."); } + + if (behavior.LessonWhisperSkillFactor is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonWhisperSkillFactor must be 0–1."); + } + + if (behavior.WhisperCatchChance is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchChance must be 0–1."); + } + + if (behavior.WhisperCatchMax is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchMax must be 0–1."); + } + + if (behavior.WhisperStartChance is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperStartChance must be 0–1."); + } + + if (behavior.TalkPedagogyOpinionScale < 0f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' talkPedagogyOpinionScale cannot be negative."); + } + + if (behavior.TeacherAfterLessonWeight < 0f + || behavior.TeacherQuestionWeight < 0f + || behavior.TeacherPraiseWeight < 0f + || behavior.TeacherReprimandWeight < 0f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' teacher-talk weights cannot be negative."); + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) @@ -611,10 +649,7 @@ internal static class PeopleDefValidator private static void RequireTalkTopics(DefCatalog catalog) { var hasTalkAction = catalog.Actions.Values.Any(action => - !action.Abstract - && (action.DefName.Equals(TalkActions.Chat, StringComparison.Ordinal) - || action.DefName.Equals(TalkActions.StaffChat, StringComparison.Ordinal) - || action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal))); + !action.Abstract && TalkActions.IsTalk(action.DefName)); if (!hasTalkAction) { return; diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 9bb03ca..ed0c57c 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -210,6 +210,11 @@ public sealed class TraitDef : Def /// Pull toward topics carrying these tags when this person picks the subject. public IReadOnlyList TalkTagWeights { get; init; } = []; + + /// + /// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1. + /// + public float WhisperCatchMultiplier { get; init; } = 1f; } /// Conversation subject. Tags gate appropriateness; language is optional skill help. @@ -415,6 +420,37 @@ public sealed class BehaviorDef : Def /// Opinion at or below — enemy; never invited, lunch nodes avoided. public int TalkEnemyThreshold { get; init; } = -40; + /// + /// Lesson skill multiplier while the pupil is in a whisper circle. Missing keeps a cut, not + /// a skipped lesson — whispering is costly, not a free skip. + /// + public float LessonWhisperSkillFactor { get; init; } = 0.4f; + + /// Base chance the teacher notices one whisper circle. Traits scale it; never 1. + public float WhisperCatchChance { get; init; } = 0.4f; + + /// Ceiling after traits. A pack cannot make every whisper a sure catch. + public float WhisperCatchMax { get; init; } = 0.85f; + + /// + /// How far pedagogy 0…100 bends a pupil's opinion of the teacher. 50 is identity; above it + /// praise grows and reprimand softens, below it the reverse. + /// + public float TalkPedagogyOpinionScale { get; init; } = 0.4f; + + /// Leisure weight for a teacher taking pupils after the bell. Below lesson, above chat. + public float TeacherAfterLessonWeight { get; init; } = 4f; + + /// Relative weights when the teacher picks question / praise / reprimand. + public float TeacherQuestionWeight { get; init; } = 2f; + + public float TeacherPraiseWeight { get; init; } = 2f; + + public float TeacherReprimandWeight { get; init; } = 1f; + + /// Per-decision chance a pupil in class tries to start a whisper, before initiative. + public float WhisperStartChance { get; init; } = 0.12f; + public static IReadOnlyList DefaultOpinionBands { get; } = [ new() { Min = 70, Id = "OpinionCloseFriend" }, diff --git a/src/HSchool.Content/TalkActions.cs b/src/HSchool.Content/TalkActions.cs index 2662cf1..000cc0f 100644 --- a/src/HSchool.Content/TalkActions.cs +++ b/src/HSchool.Content/TalkActions.cs @@ -6,12 +6,22 @@ public static class TalkActions public const string Chat = "Chat"; public const string StaffChat = "StaffChat"; public const string PhoneChat = "PhoneChat"; + public const string Whisper = "Whisper"; + public const string TeacherTalk = "TeacherTalk"; public static bool IsTalk(string? actionId) => actionId is not null && (actionId.Equals(Chat, StringComparison.Ordinal) || actionId.Equals(StaffChat, StringComparison.Ordinal) - || actionId.Equals(PhoneChat, StringComparison.Ordinal)); + || actionId.Equals(PhoneChat, StringComparison.Ordinal) + || actionId.Equals(Whisper, StringComparison.Ordinal) + || actionId.Equals(TeacherTalk, StringComparison.Ordinal)); + + public static bool IsWhisper(string? actionId) => + actionId is not null && actionId.Equals(Whisper, StringComparison.Ordinal); + + public static bool IsTeacherTalk(string? actionId) => + actionId is not null && actionId.Equals(TeacherTalk, StringComparison.Ordinal); } /// Vanilla topic tags from the social slice design doc. @@ -26,3 +36,11 @@ public static class TopicTags public const string Rude = "rude"; public const string Appearance = "appearance"; } + +/// Teacher-after-lesson and reprimand topics in core. Names match TopicDef ids. +public static class TeacherTopics +{ + public const string Question = "TopicQuestion"; + public const string Praise = "TopicPraise"; + public const string Discipline = "TopicDiscipline"; +} diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 8d5a8cb..2ac9379 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -17,6 +17,7 @@ public static class Seed public const int NativeLanguageSalt = 9; public const int ClimatePresetSalt = 10; public const int ApparelSalt = 11; + public const int WhisperSalt = 12; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc index 876aa5c..46f1cba 100644 --- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc +++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc @@ -54,6 +54,24 @@ "roles": ["student"], "weight": 2, }, + { + "defName": "Whisper", + "room": "Classroom", + "minutes": 6, + "need": "Social", + "needGain": 0.15, + "roles": ["student"], + "weight": 2, + }, + { + "defName": "TeacherTalk", + "room": "Classroom", + "minutes": 8, + "need": "Social", + "needGain": 0.3, + "roles": ["staff"], + "weight": 0, + }, { "defName": "WalkCorridor", "room": "Corridor", diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 314599a..9cf4bd0 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -74,8 +74,18 @@ "talkNoLanguageOpinionMultiplier": 0.15, "talkPhoneOpinionMultiplier": 0.5, "talkCommunicationOpinionScale": 0.5, - "talkCircleMin": 2, - "talkCircleMax": 4, - "talkFriendThreshold": 40, - "talkEnemyThreshold": -40, + "talkCircleMin": 2, + "talkCircleMax": 4, + "talkFriendThreshold": 40, + "talkEnemyThreshold": -40, + // Whisper on lesson (slice 9 phase 43). Catch chance is not 1 even with loud traits. + "lessonWhisperSkillFactor": 0.4, + "whisperCatchChance": 0.4, + "whisperCatchMax": 0.85, + "whisperStartChance": 0.12, + "talkPedagogyOpinionScale": 0.4, + "teacherAfterLessonWeight": 4, + "teacherQuestionWeight": 2, + "teacherPraiseWeight": 2, + "teacherReprimandWeight": 1, } diff --git a/src/HSchool.Server/mods/core/defs/topics/vanilla.jsonc b/src/HSchool.Server/mods/core/defs/topics/vanilla.jsonc index bcefc76..f21941d 100644 --- a/src/HSchool.Server/mods/core/defs/topics/vanilla.jsonc +++ b/src/HSchool.Server/mods/core/defs/topics/vanilla.jsonc @@ -3,6 +3,7 @@ "defName": "TopicStudy", "tags": ["study"], "roles": ["student", "staff"], + "whisperOnLesson": true, "opinionShift": 2, }, { @@ -10,12 +11,14 @@ "tags": ["games"], "roles": ["student"], "age": { "min": 7, "max": 18 }, + "whisperOnLesson": true, "opinionShift": 3, }, { "defName": "TopicFood", "tags": ["food"], "roles": ["student", "staff"], + "whisperOnLesson": true, "opinionShift": 2, }, { @@ -28,12 +31,14 @@ "defName": "TopicSport", "tags": ["sport"], "roles": ["student", "staff"], + "whisperOnLesson": true, "opinionShift": 3, }, { "defName": "TopicGossip", "tags": ["gossip"], "roles": ["student", "staff"], + "whisperOnLesson": true, "opinionShift": 1, }, { @@ -47,6 +52,25 @@ "tags": ["appearance"], "roles": ["student"], "age": { "min": 11, "max": 18 }, + "whisperOnLesson": true, "opinionShift": 1, }, + { + "defName": "TopicQuestion", + "tags": ["study"], + "roles": ["student", "staff"], + "opinionShift": 2, + }, + { + "defName": "TopicPraise", + "tags": ["study"], + "roles": ["student", "staff"], + "opinionShift": 4, + }, + { + "defName": "TopicDiscipline", + "tags": ["study"], + "roles": ["student", "staff"], + "opinionShift": -6, + }, ] diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc index 0354cbf..c917f6f 100644 --- a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc +++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc @@ -35,6 +35,7 @@ "incompatible": ["Leader", "Bully"], "wageAsk": -8, "talkInitiative": 0.45, + "whisperCatchMultiplier": 0.55, }, { "defName": "Leader", @@ -42,6 +43,7 @@ "incompatible": ["Quiet"], "wageAsk": 10, "talkInitiative": 1.6, + "whisperCatchMultiplier": 1.25, "skillModifiers": [ { "skill": "History", "offset": 4 }, ], @@ -107,5 +109,6 @@ "incompatible": ["Quiet"], "talkInitiative": 1.4, "talkCircleBonus": 1, + "whisperCatchMultiplier": 1.4, }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index ece7ea4..a0e28b9 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -6,6 +6,8 @@ "Chat": "Chat", "StaffChat": "Staff chat", "PhoneChat": "Phone chat", + "Whisper": "Whisper", + "TeacherTalk": "Teacher talk", "WalkCorridor": "Walk the corridor", "WalkYard": "Walk the yard", "Behavior": "Behavior rules", @@ -194,6 +196,8 @@ "ActionStarted": "started: {0}", "ActionEnded": "finished: {0}", "TalkEnded": "talked about {0}", + "Whispered": "whispered", + "TeacherInterrupted": "the teacher cut them off", "TopicStudy": "schoolwork", "TopicGames": "games", "TopicFood": "food", @@ -202,6 +206,9 @@ "TopicGossip": "gossip", "TopicRude": "rough talk", "TopicAppearance": "looks", + "TopicQuestion": "a subject question", + "TopicPraise": "praise", + "TopicDiscipline": "discipline", "ApparelReplaced": "got a new {0}", "ApparelChanged": "changed clothes: {0}", "LessonNoTeacher": "lesson without a teacher: {0}", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index a4dfa35..f54417f 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -6,6 +6,8 @@ "Chat": "Разговор", "StaffChat": "Разговор в учительской", "PhoneChat": "Разговор по телефону", + "Whisper": "Шёпот", + "TeacherTalk": "Разговор учителя", "WalkCorridor": "Прогулка по коридору", "WalkYard": "Прогулка во дворе", "Behavior": "Правила поведения", @@ -194,6 +196,8 @@ "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}", "TalkEnded": "говорил о {0}", + "Whispered": "шептались", + "TeacherInterrupted": "учитель оборвал", "TopicStudy": "учёбе", "TopicGames": "играх", "TopicFood": "еде", @@ -202,6 +206,9 @@ "TopicGossip": "сплетнях", "TopicRude": "грубом", "TopicAppearance": "внешности", + "TopicQuestion": "вопросе по предмету", + "TopicPraise": "похвале", + "TopicDiscipline": "дисциплине", "ApparelReplaced": "получил новую {0}", "ApparelChanged": "переоделся: {0}", "LessonNoTeacher": "урок без учителя: {0}", diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index 00c99e7..da21f4b 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -57,7 +57,12 @@ internal static class LessonLearningSystem in People, (ref PersonIdentity identity, ref PersonSkills skills, ref PersonTraits traits, ref PersonNeeds needs, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => { - if (activity.IsActive || !IsStanding(presence)) + if (!IsStanding(presence)) + { + return; + } + + if (activity.IsActive && !TalkActions.IsWhisper(activity.ActionId)) { return; } @@ -98,6 +103,8 @@ internal static class LessonLearningSystem school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTextbook, lesson.Subject); } + textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules); + IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found : new Dictionary(StringComparer.Ordinal); diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 753fe4f..f462999 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -13,6 +13,8 @@ public static class PersonLogTypes public const string ActionStarted = "action-started"; public const string ActionEnded = "action-ended"; public const string TalkEnded = "talk-ended"; + public const string Whispered = "whispered"; + public const string TeacherInterrupted = "teacher-interrupted"; public const string ApparelReplaced = "apparel-replaced"; public const string ApparelChanged = "apparel-changed"; public const string LessonNoTeacher = "lesson-no-teacher"; @@ -29,6 +31,16 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, public string Caption(DefCatalog catalog, string locale) { ArgumentNullException.ThrowIfNull(catalog); + if (Type.Equals(PersonLogTypes.Whispered, StringComparison.Ordinal)) + { + return catalog.Text(locale, "Whispered"); + } + + if (Type.Equals(PersonLogTypes.TeacherInterrupted, StringComparison.Ordinal)) + { + return catalog.Text(locale, "TeacherInterrupted"); + } + if (ThingDef is null) { return Type; diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index 694a754..e05d0f8 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -333,7 +333,7 @@ internal static class PresenceSystem : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index); if (activity.IsActive && TalkActions.IsTalk(activity.ActionId)) { - if (!bound) + if (!bound || TalkActions.IsWhisper(activity.ActionId)) { return null; } @@ -432,7 +432,48 @@ internal static class PresenceSystem presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false); } - return decision.StartAction is not null && !activity.IsActive ? decision.StartAction : null; + if (decision.StartAction is not null && !activity.IsActive) + { + return decision.StartAction; + } + + if (!activity.IsActive + && decision.WalkTo is null + && ShouldStartWhisper(school, person, state)) + { + return TalkActions.Whisper; + } + + return null; + } + + private static bool ShouldStartWhisper(School school, Person person, ActorState state) + { + if (!state.BoundToLesson + || !person.IsStudent + || state.IsWalking + || state.NodeId is null + || state.DutyRoom is null + || !state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal)) + { + return false; + } + + var location = school.Map?.NodeDef(state.NodeId); + if (location is null || !location.Equals("Classroom", StringComparison.Ordinal)) + { + return false; + } + + var rules = school.Catalog?.BehaviorRules; + var chance = (rules?.WhisperStartChance ?? 0.12f) * TalkCircles.Initiative(person.Traits, school.Catalog!); + var roll = TalkCircles.Roll01( + Seed.Mix( + school.PeopleSeed, + person.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.WhisperSalt + (school.Clock.Time.Minute / 2))); + return TalkCircles.WhisperCaught(roll, chance, 0.5f, 1f); } private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId) diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 734ada8..914f2cd 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -18,6 +18,9 @@ internal sealed class ActiveTalkCircle public required List Members { get; init; } public float RemainingMinutes { get; set; } + + /// Set once a teacher in the room has rolled to notice this whisper. + public bool CatchChecked { get; set; } } /// Forms 2–4 person talk circles, applies outcomes, writes topic log lines. @@ -59,7 +62,7 @@ internal static class TalkCircleSystem } var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal)); - if (person is null) + if (person is null || !RoleFits(action, person)) { return false; } @@ -115,6 +118,11 @@ internal static class TalkCircleSystem { circle.RemainingMinutes -= minutes; SyncActivity(school, circle); + if (TalkActions.IsWhisper(circle.ActionId) && TryCatchWhisper(school, circle)) + { + continue; + } + if (circle.RemainingMinutes > 0) { continue; @@ -173,7 +181,8 @@ internal static class TalkCircleSystem { var rules = school.Catalog!.BehaviorRules; var max = TalkCircles.MaxSize(action.DefName, rules, initiator.Traits, school.Catalog); - var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false); + var studentsOnly = TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName); + var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly); var ranked = TalkCircles.RankInvitees(initiator, candidates, rules); var members = new List { initiator.Id }; foreach (var id in ranked) @@ -186,7 +195,7 @@ internal static class TalkCircleSystem members.Add(id); } - var min = rules?.TalkCircleMin ?? 2; + var min = TalkActions.IsTeacherTalk(action.DefName) ? 3 : rules?.TalkCircleMin ?? 2; if (members.Count < min) { return false; @@ -196,8 +205,17 @@ internal static class TalkCircleSystem school.Catalog, initiator, initiator.AgeOn(school.Clock.Time), - Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21)) - ?? school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName; + Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21), + whisperOnLesson: TalkActions.IsWhisper(action.DefName)); + if (TalkActions.IsTeacherTalk(action.DefName)) + { + topicId = TalkCircles.PickTeacherTopic( + school.Catalog, + rules, + Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt)); + } + + topicId ??= school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName; var circle = new ActiveTalkCircle { @@ -216,6 +234,11 @@ internal static class TalkCircleSystem { var rules = school.Catalog!.BehaviorRules; var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal)); + if ((TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName)) && !person.IsStudent) + { + return false; + } + var max = TalkCircles.MaxSize(action.DefName, rules, person.Traits, school.Catalog); if (circle.Members.Count >= max || circle.Members.Contains(personId)) { @@ -238,7 +261,7 @@ internal static class TalkCircleSystem && circle.RemainingMinutes > 0) { var rules = school.Catalog!.BehaviorRules; - var max = rules?.TalkCircleMax ?? 4; + var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog); if (circle.Members.Count < max) { return circle; @@ -253,7 +276,8 @@ internal static class TalkCircleSystem School school, string exceptId, string nodeId, - bool requirePhone) + bool requirePhone, + bool studentsOnly = false) { var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var list = new List(); @@ -268,6 +292,11 @@ internal static class TalkCircleSystem return; } + if (studentsOnly && !roles.IsStudent) + { + return; + } + if (!roster.TryGetValue(identity.Id, out var person)) { return; @@ -296,6 +325,14 @@ internal static class TalkCircleSystem { school.TalkCircleByPerson[member] = circle.Id; SetActivity(school, member, action.DefName, circle.TopicId, circle.RemainingMinutes); + if (TalkActions.IsWhisper(action.DefName)) + { + school.AppendDayLog(new PersonLogEvent( + member, + school.Clock.Time, + PersonLogTypes.Whispered, + circle.TopicId)); + } } } @@ -392,6 +429,11 @@ internal static class TalkCircleSystem appearance, rules, catalog); + if (person.IsStudent && other.IsStaff) + { + delta = TalkCircles.ScaleByPedagogy(delta, SkillOf(school, other.Id, "Pedagogy"), rules); + } + if (delta == 0) { continue; @@ -417,6 +459,150 @@ internal static class TalkCircleSystem school.TalkCirclesById.Remove(circle.Id); } + private static bool TryCatchWhisper(School school, ActiveTalkCircle circle) + { + if (circle.CatchChecked) + { + return false; + } + + var teacherId = StaffStandingIn(school, circle.NodeId); + if (teacherId is null) + { + return false; + } + + circle.CatchChecked = true; + var rules = school.Catalog!.BehaviorRules; + var traits = new List(); + foreach (var memberId in circle.Members) + { + var member = school.Roster!.People.FirstOrDefault(person => person.Id.Equals(memberId, StringComparison.Ordinal)); + if (member is not null) + { + traits.AddRange(member.Traits); + } + } + + var roll = TalkCircles.Roll01( + Seed.Mix(school.PeopleSeed, circle.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt)); + var multiplier = TalkCircles.WhisperCatchMultiplier(traits, school.Catalog); + if (!TalkCircles.WhisperCaught( + roll, + rules?.WhisperCatchChance ?? 0.4f, + rules?.WhisperCatchMax ?? 0.85f, + multiplier)) + { + return false; + } + + ConvertToReprimand(school, circle, teacherId); + return true; + } + + private static void ConvertToReprimand(School school, ActiveTalkCircle circle, string teacherId) + { + var pupils = circle.Members.ToArray(); + foreach (var member in pupils) + { + school.AppendDayLog(new PersonLogEvent( + member, + school.Clock.Time, + PersonLogTypes.TeacherInterrupted, + circle.TopicId)); + school.TalkCircleByPerson.Remove(member); + ClearActivity(school, member); + } + + school.TalkCirclesById.Remove(circle.Id); + if (!school.Catalog!.Actions.TryGetValue(TalkActions.TeacherTalk, out var action)) + { + return; + } + + var members = new List { teacherId }; + var max = TalkCircles.MaxSize(TalkActions.TeacherTalk, school.Catalog.BehaviorRules, [], school.Catalog); + foreach (var id in pupils.OrderBy(value => value, StringComparer.Ordinal)) + { + if (members.Count >= max) + { + break; + } + + if (!members.Contains(id, StringComparer.Ordinal)) + { + members.Add(id); + } + } + + if (members.Count < 3) + { + return; + } + + var reprimand = new ActiveTalkCircle + { + Id = $"reprimand-{teacherId}-{circle.NodeId}-{school.Clock.Time.Ticks}", + ActionId = TalkActions.TeacherTalk, + TopicId = TeacherTopics.Discipline, + NodeId = circle.NodeId, + Members = members, + RemainingMinutes = action.Minutes, + CatchChecked = true, + }; + Register(school, reprimand, action); + } + + private static string? StaffStandingIn(School school, string nodeId) + { + string? found = null; + school.World.Query( + in People, + (ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => + { + if (found is not null + || !roles.IsStaff + || presence.NodeId is null + || !presence.NodeId.Equals(nodeId, StringComparison.Ordinal) + || presence.Path.Length > 0 + || presence.RemainingMinutes > 0) + { + return; + } + + found = identity.Id; + }); + return found; + } + + private static bool RoleFits(ActionDef action, Person person) + { + if (action.Roles.Count == 0) + { + return true; + } + + foreach (var role in action.Roles) + { + if (role.Equals(HSchool.Content.PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && person.IsStudent) + { + return true; + } + + if (role.Equals(HSchool.Content.PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && person.IsStaff) + { + return true; + } + + if (role.Equals(HSchool.Content.PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && person.IsParent) + { + return true; + } + } + + return false; + } + private static ApparelIssue AppearanceOf(School school, Person person) { var schoolClass = person.ClassId is { } classId diff --git a/tests/HSchool.Ai.Tests/WhisperTests.cs b/tests/HSchool.Ai.Tests/WhisperTests.cs new file mode 100644 index 0000000..9bc4e12 --- /dev/null +++ b/tests/HSchool.Ai.Tests/WhisperTests.cs @@ -0,0 +1,178 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Ai.Tests; + +public class WhisperTests +{ + [Fact] + public void BlocksOnLesson_AllowsWhisper_NotChat() + { + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.Chat)); + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.StaffChat)); + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.PhoneChat)); + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.TeacherTalk)); + Assert.False(TalkCircles.BlocksOnLesson(TalkActions.Whisper)); + } + + [Fact] + public void FixedSeed_DoesNotCatchEveryWhisperWithTeacherChance() + { + var (catalog, _, _) = World(); + var rules = catalog.BehaviorRules!; + var caught = 0; + const int attempts = 40; + for (var i = 0; i < attempts; i++) + { + var roll = TalkCircles.Roll01(Seed.Mix(1, i, Seed.WhisperSalt)); + if (TalkCircles.WhisperCaught(roll, rules.WhisperCatchChance, rules.WhisperCatchMax, 1f)) + { + caught++; + } + } + + Assert.True(caught > 0); + Assert.True(caught < attempts); + Assert.True(rules.WhisperCatchMax < 1f); + } + + [Fact] + public void CatchChance_NeverReachesOne_EvenWithLoudTraits() + { + var (catalog, _, _) = World(); + var multiplier = TalkCircles.WhisperCatchMultiplier(["Outgoing", "Leader"], catalog); + Assert.True(multiplier > 1f); + var rules = catalog.BehaviorRules!; + const int attempts = 80; + var missed = 0; + for (var i = 0; i < attempts; i++) + { + var roll = TalkCircles.Roll01(Seed.Mix(2, i, Seed.WhisperSalt)); + if (!TalkCircles.WhisperCaught(roll, rules.WhisperCatchChance, rules.WhisperCatchMax, multiplier)) + { + missed++; + } + } + + Assert.True(missed > 0); + } + + [Fact] + public void HighPedagogy_AfterPraise_PlusesOpinionMoreThanLow() + { + var (catalog, _, _) = World(); + var rules = catalog.BehaviorRules!; + var topic = catalog.Topics[TeacherTopics.Praise]; + var from = Person("pupil", ["RussianLanguage"]); + var to = Person("teacher", ["RussianLanguage"]); + var raw = TalkCircles.OpinionDelta( + from, + to, + topic, + 50f, + sharedLanguage: true, + TalkActions.TeacherTalk, + ApparelIssue.None, + rules, + catalog); + + Assert.True(raw > 0); + var high = TalkCircles.ScaleByPedagogy(raw, 90f, rules); + var low = TalkCircles.ScaleByPedagogy(raw, 20f, rules); + Assert.True(high > low); + } + + [Fact] + public void BoundToLesson_SocialZero_StartsWhisperNotChat() + { + var (catalog, map, walks) = World(); + var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; + var decision = DecisionPlanner.Decide( + catalog, + map, + walks, + new ActorState( + classroom, + classroom, + false, + false, + true, + false, + false, + true, + classroom, + Needs(social: 0f), + Intent.None, + Talk: new TalkPlannerContext( + "p1", + new Dictionary(), + new Dictionary(), + "c1", + false, + false, + catalog.BehaviorRules)), + (_, _) => 0); + + Assert.Equal(TalkActions.Whisper, decision.StartAction); + Assert.NotEqual(TalkActions.Chat, decision.StartAction); + } + + [Fact] + public void PickTopic_OnLesson_OnlyWhisperTopics() + { + var (catalog, _, _) = World(); + var picker = Person("a", ["RussianLanguage"]); + for (var seed = 0; seed < 30; seed++) + { + var id = TalkCircles.PickTopic(catalog, picker, age: 14, seed, whisperOnLesson: true); + Assert.NotNull(id); + Assert.True(catalog.Topics[id!].WhisperOnLesson); + } + } + + private static Dictionary Needs(float social = 1f) => new(StringComparer.Ordinal) + { + ["Social"] = social, + ["Hunger"] = 1f, + ["Sleep"] = 1f, + ["Toilet"] = 1f, + }; + + private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) World() + { + var (catalog, map) = Fixtures.Vanilla(); + return (catalog, map, WalkGraph.Build(catalog, map)); + } + + private static Person Person(string id, string[] languages) + { + var skills = languages.ToDictionary(skill => skill, _ => 60, StringComparer.Ordinal); + skills["Communication"] = 50; + var cases = new CaseTable + { + Nom = "A", + Gen = "A", + Dat = "A", + Acc = "A", + Ins = "A", + Pre = "A", + }; + return new Person + { + Id = id, + FamilyId = "f1", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName("A", "B", "C", cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = skills, + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Content.Tests/BehaviorDefTests.cs b/tests/HSchool.Content.Tests/BehaviorDefTests.cs index 6e34ada..d9e30bc 100644 --- a/tests/HSchool.Content.Tests/BehaviorDefTests.cs +++ b/tests/HSchool.Content.Tests/BehaviorDefTests.cs @@ -113,6 +113,8 @@ public class BehaviorDefTests Assert.NotNull(catalog.BehaviorRules); Assert.Equal(0, catalog.BehaviorRules.CommuteRainMinutes); Assert.Equal(0, catalog.BehaviorRules.CommuteSnowMinutes); + Assert.Equal(0.4f, catalog.BehaviorRules.LessonWhisperSkillFactor); + Assert.Equal(0.4f, catalog.BehaviorRules.WhisperCatchChance); } [Theory] diff --git a/tests/HSchool.Content.Tests/TopicDefTests.cs b/tests/HSchool.Content.Tests/TopicDefTests.cs index 27745c2..77b0bc6 100644 --- a/tests/HSchool.Content.Tests/TopicDefTests.cs +++ b/tests/HSchool.Content.Tests/TopicDefTests.cs @@ -16,6 +16,10 @@ public class TopicDefTests Assert.True(catalog.Topics.Count >= 8); Assert.True(catalog.Topics.ContainsKey("TopicStudy")); Assert.Contains("study", catalog.Topics["TopicStudy"].Tags); + Assert.True(catalog.Topics["TopicGames"].WhisperOnLesson); + Assert.False(catalog.Topics["TopicRude"].WhisperOnLesson); + Assert.True(catalog.Topics.ContainsKey("TopicPraise")); + Assert.True(catalog.Topics.ContainsKey("TopicDiscipline")); } [Fact] diff --git a/tests/HSchool.Simulation.Tests/WhisperTests.cs b/tests/HSchool.Simulation.Tests/WhisperTests.cs new file mode 100644 index 0000000..b4ee0a7 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/WhisperTests.cs @@ -0,0 +1,301 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class WhisperTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc); + + [Fact] + public void WhisperOnLesson_GainsLessThanQuietLesson() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + AdvanceTo(school, LessonStart); + 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); + PlaceTextbook(school, firstId, "Mathematics"); + PlaceTextbook(school, secondId, "Mathematics"); + Assert.True(school.TryStartAction(firstId, TalkActions.Whisper)); + Assert.Equal(TalkActions.Whisper, ActivityOf(school, firstId)); + Assert.Equal(TalkActions.Whisper, ActivityOf(school, secondId)); + + var whisperBefore = SkillOf(school, firstId, "Mathematics"); + var quietId = schoolClass.PupilIds.First(id => id != firstId && id != secondId); + PlaceAt(school, quietId, homeroom); + PlaceTextbook(school, quietId, "Mathematics"); + var quietBefore = SkillOf(school, quietId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + var whisperGain = SkillOf(school, firstId, "Mathematics") - whisperBefore; + var quietGain = SkillOf(school, quietId, "Mathematics") - quietBefore; + Assert.True(quietGain > 0); + Assert.True(whisperGain > 0); + Assert.True(quietGain > whisperGain); + } + } + + [Fact] + public void WithoutTeacherInNode_WhisperIsNotCaught() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + AdvanceTo(school, LessonStart); + 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, "restroom-1"); + Assert.True(school.TryStartAction(firstId, TalkActions.Whisper)); + + TalkCircleSystem.Apply(school, 0.5d); + + Assert.Equal(TalkActions.Whisper, ActivityOf(school, firstId)); + Assert.DoesNotContain( + school.DayLog, + row => (row.PersonId == firstId || row.PersonId == secondId) + && row.Type.Equals(PersonLogTypes.TeacherInterrupted, StringComparison.Ordinal) + && row.Time == school.Clock.Time); + Assert.Contains( + school.DayLog, + row => row.PersonId == firstId + && row.Type.Equals(PersonLogTypes.Whispered, StringComparison.Ordinal) + && row.Time == school.Clock.Time); + Assert.Equal( + "шептались", + school.DayLog.Last(row => row.Type == PersonLogTypes.Whispered).Caption(school.Catalog!, "ru")); + } + } + + [Fact] + public void FixedSeed_WithTeacherInRoom_DoesNotCatchEveryWhisper() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + AdvanceTo(school, LessonStart); + var caught = 0; + var missed = 0; + 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)) + { + caught++; + Assert.Equal( + "учитель оборвал", + school.DayLog.Last(row => row.Type == PersonLogTypes.TeacherInterrupted).Caption(school.Catalog!, "ru")); + } + else + { + missed++; + Assert.Equal(TalkActions.Whisper, ActivityOf(school, firstId)); + } + } + + Assert.True(caught > 0); + Assert.True(missed > 0); + } + } + + [Fact] + public void OrdinaryChat_DoesNotStartOnLesson() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + AdvanceTo(school, LessonStart); + 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.False(school.TryStartAction(firstId, TalkActions.Chat)); + Assert.Null(ActivityOf(school, firstId)); + Assert.True(school.TryStartAction(firstId, TalkActions.Whisper)); + } + } + + [Fact] + public void TeacherAfterLesson_TakesTwoToFourPupils() + { + var (school, homeroom, firstId, secondId, teacherId) = TwoPupilsAndTeacher(); + using (school) + { + var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Contains(firstId)); + var thirdId = schoolClass.PupilIds.First(id => id != firstId && id != secondId); + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 16, 0, DateTimeKind.Utc)); + foreach (var id in schoolClass.PupilIds) + { + PlaceAt(school, id, id == firstId || id == secondId || id == thirdId ? homeroom : "corridor-1"); + } + + PlaceAt(school, firstId, homeroom); + PlaceAt(school, secondId, homeroom); + PlaceAt(school, thirdId, homeroom); + PlaceAt(school, teacherId, homeroom); + + Assert.True(school.TryStartAction(teacherId, TalkActions.TeacherTalk)); + var members = new[] { firstId, secondId, thirdId, teacherId } + .Count(id => ActivityOf(school, id) == TalkActions.TeacherTalk); + Assert.InRange(members, 3, 5); + var pupils = new[] { firstId, secondId, thirdId } + .Count(id => ActivityOf(school, id) == TalkActions.TeacherTalk); + Assert.InRange(pupils, 2, 4); + } + } + + private static (School School, string Homeroom, string FirstId, string SecondId, string TeacherId) TwoPupilsAndTeacher() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f); + Assert.Equal(StaffingError.None, hired.Error); + roster = hired.Roster; + pool = hired.Pool; + var hiredId = roster.People.First(person => person.IsStaff).Id; + var schoolClass = roster.Classes.First(row => + row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); + var school = School.Create(1, "Шёпот", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.SetTimetable(new Timetable( + [new LessonPlacement(schoolClass.Id, "Mathematics", hiredId, schoolClass.RoomId, Day: 1, Period: 1)], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + var pupils = schoolClass.PupilIds + .Select(id => school.Roster!.People.First(person => person.Id == id)) + .Take(2) + .ToArray(); + return (school, schoolClass.RoomId, pupils[0].Id, pupils[1].Id, hiredId); + } + + private static void PlaceTextbook(School school, string personId, string subject) + { + var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal)); + if (person.Items is not IList items || items.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate items for {personId}."); + } + + for (var i = items.Count - 1; i >= 0; i--) + { + if (items[i].Subject is not null) + { + items.RemoveAt(i); + } + } + + items.Add(new InventoryItem("Textbook", Color: null, Condition: 1f, ItemLocations.Bag, subject)); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static void PlaceAt(School school, string personId, string nodeId) + { + TalkCircleSystem.Interrupt(school, personId); + school.TalkCircleByPerson.Remove(personId); + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + presence = new Presence(nodeId, 0f, nodeId, false, []); + activity = PersonActivity.Idle; + intent = Intent.None; + } + }); + } + + private static string? ActivityOf(School school, string personId) => + school.CapturePresence().Single(row => row.PersonId == personId).ActionId; + + private static float SkillOf(School school, string personId, string skill) + { + var value = float.NaN; + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonSkills skills) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + value = skills.Values.GetValueOrDefault(skill); + } + }); + return value; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +} From 089917025aa977bf78dd536966e6c91921445864 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 10:36:06 +0300 Subject: [PATCH 2/2] Mark phase 43 whisper as complete. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index a5f4c21..8a84793 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -203,7 +203,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [42. Темы и кружки](42-talk-circles.md) | ✅ | `TopicDef`, 2–4 человека, учительская, телефон, вместе | -| [43. Шёпот и учитель](43-whisper.md) | ⬜ | Шёпот на уроке, шанс оборвать, разговор после звонка | +| [43. Шёпот и учитель](43-whisper.md) | ✅ | Шёпот на уроке, шанс оборвать, разговор после звонка | 42 стоит на 41; 43 — на 42.