Scale lesson gain by warmth the same way hunger already did.

The bar was live and drove dressing; a frozen pupil in class still learned as if it were May.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 09:06:50 +03:00
co-authored by Cursor
parent aaf539cacc
commit 274814aecd
10 changed files with 117 additions and 24 deletions
+4 -5
View File
@@ -107,9 +107,8 @@
Сдвиг — поле на `TraitDef` (смещение комфортной температуры), не список фамилий в коде. Мод
добавляет «мерзлявый» тем же полем.
Голодный учится хуже уже сейчас. Замёрзший в этом срезе **ещё не** получает штраф к уроку: нужда
видна и двигает переодевание, но таблицу оценок мы не заводим. Когда появятся последствия состояния,
тепло встанет рядом с голодом.
Голодный учится хуже уже сейчас. Замёрзший — тоже: та же `NeedFactor`, что у голода. Нужда
видна, двигает переодевание и режет рост навыка на уроке. Оценок как журнала нет.
## Один ThingDef, три роли
@@ -386,7 +385,7 @@
- Магазины, деньги за одежду, стирка, кража, выдача со склада игроком.
- Игрок одевает человека с карточки.
- Штраф к уроку от холода, болезни от промокания, прогул «не в форме» как оценка.
- Болезни от промокания, прогул «не в форме» как оценка. Штраф к уроку от холода — фаза 51.
- Смена страны или климатического пресета у живой школы.
- Почасовая анимация погоды, ветер, грязь, зонты как система.
- Шкафчики сотрудников, раздевалки в каждом корпусе сверх двух ванильных.
@@ -428,4 +427,4 @@
| История | Только этот человек, только сегодня, HTTP, поиск и пейджинг |
| Инвентарь в сейве | На человеке в `people.json`, писать при содержательной смене |
| Игрок надевает | Нет |
| Погода влияет на ИИ | Да: переодеться. Ходьбу и урок в этом срезе не штрафует |
| Погода влияет на ИИ | Да: переодеться. Ходьбу не штрафует. Урок — через нужду тепла |
+10 -10
View File
@@ -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] Учителя нет в кабинете — лога холода нет (урок не учил)
## Критерий готовности
+3 -2
View File
@@ -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
{
+5 -4
View File
@@ -3,9 +3,8 @@ using HSchool.Content;
namespace HSchool.Ai;
/// <summary>
/// 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.
/// </summary>
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);
@@ -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",
}
@@ -192,5 +192,6 @@
"ApparelReplaced": "получил новую {0}",
"ApparelChanged": "переоделся: {0}",
"LessonNoTeacher": "урок без учителя: {0}",
"LessonCold": "замёрз на уроке: {0}",
"core": "Базовая игра",
}
@@ -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<string, float> taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
? found
: new Dictionary<string, float>(StringComparer.Ordinal);
@@ -108,7 +114,8 @@ internal static class LessonLearningSystem
hours,
hunger,
TraitOffset(catalog, traits, share.Skill),
teacherSkill);
teacherSkill,
warmth);
}
});
}
+7 -2
View File
@@ -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";
}
/// <summary>
@@ -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;
@@ -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()
{
@@ -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)
{