From 1b3479f984c435a0e27b43192ec9ddfe5a59023b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 09:27:46 +0300 Subject: [PATCH] Halve lesson gain when the bag has no textbook for the subject. Locker and home do not count. A pack without the field keeps vanilla 0.5. Co-authored-by: Cursor --- docs/phases/52-textbook-lesson.md | 22 ++-- docs/protocol.md | 3 +- src/HSchool.Ai/LessonLearning.cs | 32 ++++- src/HSchool.Content/PeopleDefValidator.cs | 5 + src/HSchool.Content/PeopleDefs.cs | 6 + .../mods/core/defs/behavior/rules.jsonc | 2 + .../mods/core/localizations/en.jsonc | 1 + .../mods/core/localizations/ru.jsonc | 1 + .../LessonLearningSystem.cs | 15 ++- src/HSchool.Simulation/PersonLog.cs | 8 +- tests/HSchool.Ai.Tests/LessonLearningTests.cs | 35 ++++++ .../HSchool.Content.Tests/BehaviorDefTests.cs | 33 ++++++ .../LessonTeacherPresentTests.cs | 109 ++++++++++++++++++ 13 files changed, 252 insertions(+), 20 deletions(-) diff --git a/docs/phases/52-textbook-lesson.md b/docs/phases/52-textbook-lesson.md index 136cbf5..7619250 100644 --- a/docs/phases/52-textbook-lesson.md +++ b/docs/phases/52-textbook-lesson.md @@ -12,20 +12,20 @@ ## Задачи -- [ ] Рост урока полный, только если в **сумке** есть экземпляр с `subject` этого предмета -- [ ] Шкафчик и дом не считаются: ученик в кабинете -- [ ] Нет учебника — множитель `BehaviorDef.lessonNoTextbookFactor`, ваниль 0.5 -- [ ] Валидатор: число 0–1 -- [ ] Лог `lesson-no-textbook` один раз на предмет в сутки; `thingDef` — предмет -- [ ] `docs/protocol.md` — тип лога +- [x] Рост урока полный, только если в **сумке** есть экземпляр с `subject` этого предмета +- [x] Шкафчик и дом не считаются: ученик в кабинете +- [x] Нет учебника — множитель `BehaviorDef.lessonNoTextbookFactor`, ваниль 0.5 +- [x] Валидатор: число 0–1 +- [x] Лог `lesson-no-textbook` один раз на предмет в сутки; `thingDef` — предмет +- [x] `docs/protocol.md` — тип лога ## Тесты, без которых фаза не закрыта -- [ ] Учебник математики в сумке → полный рост (при учителе на месте) -- [ ] Тот же учебник в шкафчике или дома → рост как с коэффициентом 0.5 -- [ ] Учебник другого предмета в сумке не закрывает этот урок -- [ ] Одна строка лога за сутки на предмет, не по числу тиков -- [ ] Пак без поля — 0.5, каталог не падает +- [x] Учебник математики в сумке → полный рост (при учителе на месте) +- [x] Тот же учебник в шкафчике или дома → рост как с коэффициентом 0.5 +- [x] Учебник другого предмета в сумке не закрывает этот урок +- [x] Одна строка лога за сутки на предмет, не по числу тиков +- [x] Пак без поля — 0.5, каталог не падает ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 580c40d..6269500 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -417,7 +417,8 @@ arrive in one response. Row `type` values: `action-started`, `action-ended`, `apparel-replaced` (morning issue, when phase 34 appends it), `apparel-changed` (dressing, when phase 35 appends it), `lesson-no-teacher` (the assigned teacher was not standing in the lesson room), -`lesson-cold` (warmth below the behaviour threshold during a lesson that otherwise taught). +`lesson-cold` (warmth below the behaviour threshold during a lesson that otherwise taught), +`lesson-no-textbook` (the bag had no textbook for that lesson; locker and home do not count). `thingDef` is the action, apparel or subject def the caption was built from. ```json diff --git a/src/HSchool.Ai/LessonLearning.cs b/src/HSchool.Ai/LessonLearning.cs index 1c62555..30cede7 100644 --- a/src/HSchool.Ai/LessonLearning.cs +++ b/src/HSchool.Ai/LessonLearning.cs @@ -1,10 +1,12 @@ using HSchool.Content; +using HSchool.People; namespace HSchool.Ai; /// /// How much a lesson adds to one skill this step. Hungry or cold learns worse; trait offsets and -/// the teacher's subject skill scale the rate. Missing warmth is treated as 1. +/// the teacher's subject skill scale the rate. Missing warmth is treated as 1. A missing textbook +/// uses . /// public static class LessonLearning { @@ -45,6 +47,28 @@ public static class LessonLearning return count == 0 ? 0f : total / count; } + /// + /// True when a bag instance carries this lesson's . + /// Locker and home do not count — the pupil is already in class. + /// + public static bool HasTextbookInBag(IReadOnlyList items, string subject) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + + foreach (var item in items) + { + if (item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal) + && item.Subject is not null + && item.Subject.Equals(subject, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + public static float Gain( float current, SkillDef skill, @@ -54,7 +78,8 @@ public static class LessonLearning float hunger, int traitOffset, float teacherSkill, - float warmth = 1f) + float warmth = 1f, + float textbookFactor = 1f) { ArgumentNullException.ThrowIfNull(skill); var delta = share @@ -63,7 +88,8 @@ public static class LessonLearning * NeedFactor(hunger) * NeedFactor(warmth) * TraitFactor(traitOffset) - * TeacherFactor(teacherSkill); + * TeacherFactor(teacherSkill) + * textbookFactor; return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max); } } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 468478f..42e9407 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -506,6 +506,11 @@ internal static class PeopleDefValidator throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonSkillPerHour cannot be negative."); } + if (behavior.LessonNoTextbookFactor is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonNoTextbookFactor must be 0–1."); + } + if (behavior.SwitchMargin < 0f) { throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' switchMargin cannot be negative."); diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 078f529..f4079b3 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -274,6 +274,12 @@ public sealed class BehaviorDef : Def /// Skill points a lesson adds per game hour, before traits and need state. public float LessonSkillPerHour { get; init; } + /// + /// Multiplier when the bag has no textbook for this lesson. Locker and home do not count. + /// A pack without the field keeps vanilla half-gain so the catalog still loads. + /// + public float LessonNoTextbookFactor { get; init; } = 0.5f; + /// Inclusive range of extra commute minutes rolled per person per day. public int CommuteSlackMin { get; init; } diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index f87b1e6..512f54e 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -4,6 +4,8 @@ "needThreshold": 0.35, // Skill points a lesson adds per game hour, before traits and need state. "lessonSkillPerHour": 0.05, + // No textbook for this lesson in the bag (locker and home do not count). + "lessonNoTextbookFactor": 0.5, // Inclusive extra commute minutes. 0–6 matches the previous hardcoded roll so // the same seed still arrives at the same minute. "commuteSlackMin": 0, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index f39e04f..ece7ea4 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -206,5 +206,6 @@ "ApparelChanged": "changed clothes: {0}", "LessonNoTeacher": "lesson without a teacher: {0}", "LessonCold": "too cold in class: {0}", + "LessonNoTextbook": "no textbook: {0}", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index eb590f9..a4dfa35 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -206,5 +206,6 @@ "ApparelChanged": "переоделся: {0}", "LessonNoTeacher": "урок без учителя: {0}", "LessonCold": "замёрз на уроке: {0}", + "LessonNoTextbook": "нет учебника: {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index 9594b51..00c99e7 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -8,8 +8,9 @@ namespace HSchool.Simulation; /// /// Grows skills for people who are actually in the lesson: at the room, not walking, not off -/// doing something else, and with the assigned teacher standing there. The formula lives in -/// . +/// doing something else, and with the assigned teacher standing there. A pupil without today's +/// textbook in the bag learns at . The formula +/// lives in . /// internal static class LessonLearningSystem { @@ -90,6 +91,13 @@ internal static class LessonLearningSystem school.TryLogLessonOnce(personId, PersonLogTypes.LessonCold, lesson.Subject); } + var textbookFactor = 1f; + if (person.IsStudent && !LessonLearning.HasTextbookInBag(person.Items, lesson.Subject)) + { + textbookFactor = rules.LessonNoTextbookFactor; + school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTextbook, lesson.Subject); + } + IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found : new Dictionary(StringComparer.Ordinal); @@ -115,7 +123,8 @@ internal static class LessonLearningSystem hunger, TraitOffset(catalog, traits, share.Skill), teacherSkill, - warmth); + warmth, + textbookFactor); } }); } diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index c5b97a4..753fe4f 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -17,6 +17,7 @@ public static class PersonLogTypes public const string ApparelChanged = "apparel-changed"; public const string LessonNoTeacher = "lesson-no-teacher"; public const string LessonCold = "lesson-cold"; + public const string LessonNoTextbook = "lesson-no-textbook"; } /// @@ -62,14 +63,17 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, } if (Type.Equals(PersonLogTypes.LessonNoTeacher, StringComparison.Ordinal) - || Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal)) + || Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal) + || Type.Equals(PersonLogTypes.LessonNoTextbook, StringComparison.Ordinal)) { var name = catalog.Subjects.TryGetValue(ThingDef, out var subject) ? catalog.Label(locale, subject) : ThingDef; var key = Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal) ? "LessonCold" - : "LessonNoTeacher"; + : Type.Equals(PersonLogTypes.LessonNoTextbook, StringComparison.Ordinal) + ? "LessonNoTextbook" + : "LessonNoTeacher"; return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } diff --git a/tests/HSchool.Ai.Tests/LessonLearningTests.cs b/tests/HSchool.Ai.Tests/LessonLearningTests.cs index 2bd075e..ddb34c4 100644 --- a/tests/HSchool.Ai.Tests/LessonLearningTests.cs +++ b/tests/HSchool.Ai.Tests/LessonLearningTests.cs @@ -1,4 +1,5 @@ using HSchool.Content; +using HSchool.People; namespace HSchool.Ai.Tests; @@ -97,6 +98,40 @@ public class LessonLearningTests Assert.Equal(explicitWarm, implied); } + + [Fact] + public void MissingTextbook_HalvesGain() + { + var withBook = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100, warmth: 1f, textbookFactor: 1f); + var without = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100, warmth: 1f, textbookFactor: 0.5f); + + Assert.Equal((withBook - 50) * 0.5f, without - 50, precision: 5); + } + + [Fact] + public void TextbookInBag_OnlyThatSubjectCounts() + { + var items = new[] + { + new InventoryItem("Textbook", null, 1f, ItemLocations.Bag, "Literature"), + new InventoryItem("Textbook", null, 1f, ItemLocations.Locker, "Mathematics"), + }; + + Assert.False(LessonLearning.HasTextbookInBag(items, "Mathematics")); + Assert.True(LessonLearning.HasTextbookInBag(items, "Literature")); + } + + [Fact] + public void TextbookAtHome_DoesNotCount() + { + var items = new[] + { + new InventoryItem("Textbook", null, 1f, ItemLocations.Home, "Mathematics"), + }; + + Assert.False(LessonLearning.HasTextbookInBag(items, "Mathematics")); + } + [Fact] public void MissingTeacherSkill_UsesRangeMin_AndStillGains() { diff --git a/tests/HSchool.Content.Tests/BehaviorDefTests.cs b/tests/HSchool.Content.Tests/BehaviorDefTests.cs index 0d962f5..5ebbb9e 100644 --- a/tests/HSchool.Content.Tests/BehaviorDefTests.cs +++ b/tests/HSchool.Content.Tests/BehaviorDefTests.cs @@ -64,6 +64,39 @@ public class BehaviorDefTests Assert.Contains("carry mass", error.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void MissingLessonNoTextbookFactor_DefaultsToHalf() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "needThreshold": 0.35, "lessonSkillPerHour": 0.05, "commuteSlackMin": 0, "commuteSlackMax": 6, "switchMargin": 0.15 }"""), + ]); + + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(0.5f, catalog.BehaviorRules.LessonNoTextbookFactor); + } + + [Fact] + public void LessonNoTextbookFactor_OutOfRange_FailsTheCatalog() + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "lessonNoTextbookFactor": 1.5 }"""), + ])); + + Assert.Contains("lessonNoTextbookFactor", error.Message, StringComparison.Ordinal); + } + [Fact] public void NegativeApparelWear_FailsTheCatalog() { diff --git a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs index 324c963..9674a28 100644 --- a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs +++ b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs @@ -238,6 +238,96 @@ public class LessonTeacherPresentTests } } + [Fact] + public void TextbookInBag_GainsFull() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + PlaceTextbook(school, pupilId, "Mathematics", ItemLocations.Bag); + var before = SkillOf(school, pupilId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + Assert.True(SkillOf(school, pupilId, "Mathematics") > before); + } + } + + [Theory] + [InlineData(ItemLocations.Locker)] + [InlineData(ItemLocations.Home)] + public void TextbookNotInBag_GainsHalf(string location) + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + PlaceTextbook(school, pupilId, "Mathematics", ItemLocations.Bag); + var start = SkillOf(school, pupilId, "Mathematics"); + LessonLearningSystem.Apply(school, 45); + var bagGain = SkillOf(school, pupilId, "Mathematics") - start; + + SetSkill(school, pupilId, "Mathematics", start); + PlaceTextbook(school, pupilId, "Mathematics", location); + LessonLearningSystem.Apply(school, 45); + var awayGain = SkillOf(school, pupilId, "Mathematics") - start; + + Assert.True(bagGain > 0); + Assert.Equal(bagGain * 0.5f, awayGain, precision: 4); + } + } + + [Fact] + public void OtherSubjectInBag_DoesNotCoverThisLesson() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + PlaceTextbook(school, pupilId, "Mathematics", ItemLocations.Bag); + var start = SkillOf(school, pupilId, "Mathematics"); + LessonLearningSystem.Apply(school, 45); + var mathGain = SkillOf(school, pupilId, "Mathematics") - start; + + SetSkill(school, pupilId, "Mathematics", start); + PlaceTextbook(school, pupilId, "Literature", ItemLocations.Bag); + LessonLearningSystem.Apply(school, 45); + var otherGain = SkillOf(school, pupilId, "Mathematics") - start; + + Assert.Equal(mathGain * 0.5f, otherGain, precision: 4); + } + } + + [Fact] + public void NoTextbook_LogsOncePerSubject() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + PlaceTextbook(school, pupilId, "Mathematics", ItemLocations.Home); + + LessonLearningSystem.Apply(school, 5); + LessonLearningSystem.Apply(school, 5); + + var rows = school.DayLog + .Where(row => row.PersonId == pupilId && row.Type == PersonLogTypes.LessonNoTextbook) + .ToArray(); + Assert.Single(rows); + Assert.Equal("Mathematics", rows[0].ThingDef); + Assert.Equal("нет учебника: Математика", rows[0].Caption(school.Catalog!, "ru")); + } + } + private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath( string? teacherId = null) { @@ -263,6 +353,25 @@ public class LessonTeacherPresentTests return (school, schoolClass.RoomId, pupil.Id, hiredId); } + private static void PlaceTextbook(School school, string personId, string subject, string location) + { + 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, location, subject)); + } + private static void AdvanceTo(School school, DateTime until) { while (school.Clock.Time < until)