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)