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/phases/README.md b/docs/phases/README.md
index 6a8d5b3..f7eecc4 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -238,6 +238,6 @@
| [48. Учитель в кабинете](48-lesson-teacher-present.md) | ✅ | Нет учителя на месте — нет роста навыка |
| [49. Пропуск сборки при запуске](49-skip-stale-build.md) | ✅ | `run-aspire.cmd` не гоняет MSBuild, если dll свежие |
| [50. Навык учителя](50-teacher-skill-gain.md) | ✅ | Соискатель 78 учит лучше, чем 41 |
-| [51. Тепло на уроке](51-warmth-lesson.md) | 🔄 | Замёрзший учится хуже, как голодный |
+| [51. Тепло на уроке](51-warmth-lesson.md) | ✅ | Замёрзший учится хуже, как голодный |
| [52. Учебник на уроке](52-textbook-lesson.md) | ⬜ | Нет в сумке — половинный рост |
| [53. Погода на дороге](53-weather-commute.md) | ⬜ | Снег и дождь добавляют минуты к приходу |
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 61bb087..f39e04f 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -205,5 +205,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 191a19c..eb590f9 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -205,5 +205,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 6ea98c1..c5b97a4 100644
--- a/src/HSchool.Simulation/PersonLog.cs
+++ b/src/HSchool.Simulation/PersonLog.cs
@@ -16,6 +16,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";
}
///
@@ -60,12 +61,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);
}
if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal))
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)
{