diff --git a/docs/design/inventory.md b/docs/design/inventory.md index a6caed0..bf69472 100644 --- a/docs/design/inventory.md +++ b/docs/design/inventory.md @@ -107,9 +107,8 @@ Сдвиг — поле на `TraitDef` (смещение комфортной температуры), не список фамилий в коде. Мод добавляет «мерзлявый» тем же полем. -Голодный учится хуже уже сейчас. Замёрзший в этом срезе **ещё не** получает штраф к уроку: нужда -видна и двигает переодевание, но таблицу оценок мы не заводим. Когда появятся последствия состояния, -тепло встанет рядом с голодом. +Голодный учится хуже уже сейчас. Замёрзший — тоже: та же `NeedFactor`, что у голода. Нужда +видна, двигает переодевание и режет рост навыка на уроке. Оценок как журнала нет. ## Один ThingDef, три роли @@ -386,7 +385,7 @@ - Магазины, деньги за одежду, стирка, кража, выдача со склада игроком. - Игрок одевает человека с карточки. -- Штраф к уроку от холода, болезни от промокания, прогул «не в форме» как оценка. +- Болезни от промокания, прогул «не в форме» как оценка. Штраф к уроку от холода — фаза 51. - Смена страны или климатического пресета у живой школы. - Почасовая анимация погоды, ветер, грязь, зонты как система. - Шкафчики сотрудников, раздевалки в каждом корпусе сверх двух ванильных. @@ -428,4 +427,4 @@ | История | Только этот человек, только сегодня, HTTP, поиск и пейджинг | | Инвентарь в сейве | На человеке в `people.json`, писать при содержательной смене | | Игрок надевает | Нет | -| Погода влияет на ИИ | Да: переодеться. Ходьбу и урок в этом срезе не штрафует | +| Погода влияет на ИИ | Да: переодеться. Ходьбу не штрафует. Урок — через нужду тепла | diff --git a/docs/phases/51-warmth-lesson.md b/docs/phases/51-warmth-lesson.md index f203d71..ac017fb 100644 --- a/docs/phases/51-warmth-lesson.md +++ b/docs/phases/51-warmth-lesson.md @@ -11,21 +11,21 @@ ## Задачи -- [ ] `Gain` умножает `NeedFactor(голод) × NeedFactor(тепло)` -- [ ] Нет нужды `Warmth` в каталоге — множитель тепла 1, голод как сейчас -- [ ] Лог `lesson-cold` один раз на предмет в сутки, если на уроке, который иначе учил, +- [x] `Gain` умножает `NeedFactor(голод) × NeedFactor(тепло)` +- [x] Нет нужды `Warmth` в каталоге — множитель тепла 1, голод как сейчас +- [x] Лог `lesson-cold` один раз на предмет в сутки, если на уроке, который иначе учил, тепло ниже `BehaviorDef.needThreshold`; `thingDef` — предмет -- [ ] `docs/protocol.md` — тип лога -- [ ] В [`inventory.md`](../design/inventory.md) убрать «тепло урок не штрафует»: стало рядом +- [x] `docs/protocol.md` — тип лога +- [x] В [`inventory.md`](../design/inventory.md) убрать «тепло урок не штрафует»: стало рядом с голодом ## Тесты, без которых фаза не закрыта -- [ ] Тепло 0.1 и голод 1 → меньший прирост, чем оба по 1 -- [ ] Тепло 1 и голод 0.1 → как нынешний голодный, тепло не «вылечивает» -- [ ] Нет `Warmth` в каталоге — голодный тест фазы 21 по-прежнему зелёный -- [ ] За урок на холоде одна строка лога, не по числу тиков -- [ ] Учителя нет в кабинете — лога холода нет (урок не учил) +- [x] Тепло 0.1 и голод 1 → меньший прирост, чем оба по 1 +- [x] Тепло 1 и голод 0.1 → как нынешний голодный, тепло не «вылечивает» +- [x] Нет `Warmth` в каталоге — голодный тест фазы 21 по-прежнему зелёный +- [x] За урок на холоде одна строка лога, не по числу тиков +- [x] Учителя нет в кабинете — лога холода нет (урок не учил) ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 1359a27..580c40d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -416,8 +416,9 @@ 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). `thingDef` is -the action, apparel or subject def the caption was built from. +`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). +`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 f9b523b..1c62555 100644 --- a/src/HSchool.Ai/LessonLearning.cs +++ b/src/HSchool.Ai/LessonLearning.cs @@ -3,9 +3,8 @@ using HSchool.Content; namespace HSchool.Ai; /// -/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets and the -/// teacher's subject skill scale the rate. The world stores the running total — this is just -/// the number. +/// 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. /// public static class LessonLearning { @@ -54,13 +53,15 @@ public static class LessonLearning float hours, float hunger, int traitOffset, - float teacherSkill) + float teacherSkill, + float warmth = 1f) { ArgumentNullException.ThrowIfNull(skill); var delta = share * lessonSkillPerHour * hours * NeedFactor(hunger) + * NeedFactor(warmth) * TraitFactor(traitOffset) * TeacherFactor(teacherSkill); return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max); diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index e6ece25..2cd31d6 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -192,5 +192,6 @@ "ApparelReplaced": "got a new {0}", "ApparelChanged": "changed clothes: {0}", "LessonNoTeacher": "lesson without a teacher: {0}", + "LessonCold": "too cold in class: {0}", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 28ce406..5dac8ba 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -192,5 +192,6 @@ "ApparelReplaced": "получил новую {0}", "ApparelChanged": "переоделся: {0}", "LessonNoTeacher": "урок без учителя: {0}", + "LessonCold": "замёрз на уроке: {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index 684aca3..9594b51 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -84,6 +84,12 @@ internal static class LessonLearningSystem } var hunger = needs.Values.GetValueOrDefault("Hunger", 1f); + var warmth = needs.Values.GetValueOrDefault("Warmth", 1f); + if (warmth < rules.NeedThreshold) + { + school.TryLogLessonOnce(personId, PersonLogTypes.LessonCold, lesson.Subject); + } + IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found : new Dictionary(StringComparer.Ordinal); @@ -108,7 +114,8 @@ internal static class LessonLearningSystem hours, hunger, TraitOffset(catalog, traits, share.Skill), - teacherSkill); + teacherSkill, + warmth); } }); } diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 8a4c75b..cb3638f 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -15,6 +15,7 @@ public static class PersonLogTypes public const string ApparelReplaced = "apparel-replaced"; public const string ApparelChanged = "apparel-changed"; public const string LessonNoTeacher = "lesson-no-teacher"; + public const string LessonCold = "lesson-cold"; } /// @@ -59,12 +60,16 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } - if (Type.Equals(PersonLogTypes.LessonNoTeacher, StringComparison.Ordinal)) + if (Type.Equals(PersonLogTypes.LessonNoTeacher, StringComparison.Ordinal) + || Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal)) { var name = catalog.Subjects.TryGetValue(ThingDef, out var subject) ? catalog.Label(locale, subject) : ThingDef; - return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "LessonNoTeacher"), name); + var key = Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal) + ? "LessonCold" + : "LessonNoTeacher"; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } return Type; diff --git a/tests/HSchool.Ai.Tests/LessonLearningTests.cs b/tests/HSchool.Ai.Tests/LessonLearningTests.cs index 71f0e05..2bd075e 100644 --- a/tests/HSchool.Ai.Tests/LessonLearningTests.cs +++ b/tests/HSchool.Ai.Tests/LessonLearningTests.cs @@ -67,6 +67,36 @@ public class LessonLearningTests Assert.NotEqual(skills.Values.First(), average); } + [Fact] + public void ColdLearnsLessThanWarm() + { + var warm = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100, warmth: 1f); + var cold = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100, warmth: 0.1f); + + Assert.True(cold > 50); + Assert.True(warm - 50 > cold - 50); + } + + [Fact] + public void HungerStillCutsWhenWarm() + { + var full = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, hunger: 1f, traitOffset: 0, teacherSkill: 100, warmth: 1f); + var hungry = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, hunger: 0.1f, traitOffset: 0, teacherSkill: 100, warmth: 1f); + + Assert.True(full - 50 > hungry - 50); + Assert.Equal( + LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 0.1f, 0, 100), + hungry); + } + + [Fact] + public void MissingWarmthArgument_DefaultsToFull() + { + var implied = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 0.1f, 0, 100); + var explicitWarm = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 0.1f, 0, 100, warmth: 1f); + + Assert.Equal(explicitWarm, implied); + } [Fact] public void MissingTeacherSkill_UsesRangeMin_AndStillGains() { diff --git a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs index adff8a0..324c963 100644 --- a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs +++ b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs @@ -190,6 +190,54 @@ public class LessonTeacherPresentTests } } + [Fact] + public void Cold_LogsOnce_AndGainsLess() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + var classmate = school.Roster!.Classes.First(row => row.PupilIds.Contains(pupilId)) + .PupilIds.First(id => id != pupilId); + SetPlace(school, classmate, homeroom); + SetNeed(school, pupilId, "Warmth", 0.1f); + SetNeed(school, classmate, "Warmth", 1f); + var coldBefore = SkillOf(school, pupilId, "Mathematics"); + var warmBefore = SkillOf(school, classmate, "Mathematics"); + + LessonLearningSystem.Apply(school, 5); + LessonLearningSystem.Apply(school, 5); + + var rows = school.DayLog + .Where(row => row.PersonId == pupilId && row.Type == PersonLogTypes.LessonCold) + .ToArray(); + Assert.Single(rows); + Assert.Equal("Mathematics", rows[0].ThingDef); + Assert.Equal("замёрз на уроке: Математика", rows[0].Caption(school.Catalog!, "ru")); + Assert.True(SkillOf(school, classmate, "Mathematics") - warmBefore + > SkillOf(school, pupilId, "Mathematics") - coldBefore); + } + } + + [Fact] + public void Cold_DoesNotLog_WhenTeacherIsAbsent() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, "restroom-1"); + SetNeed(school, pupilId, "Warmth", 0.1f); + + LessonLearningSystem.Apply(school, 45); + + Assert.DoesNotContain(school.DayLog, row => row.Type == PersonLogTypes.LessonCold); + } + } + private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath( string? teacherId = null) {