diff --git a/docs/phases/50-teacher-skill-gain.md b/docs/phases/50-teacher-skill-gain.md
index 01cd6c4..06ddbc2 100644
--- a/docs/phases/50-teacher-skill-gain.md
+++ b/docs/phases/50-teacher-skill-gain.md
@@ -11,18 +11,18 @@
## Задачи
-- [ ] `LessonLearning.TeacherFactor` — та же кривая, что `NeedFactor`: навык 0 → 0.25, 100 → 1
-- [ ] Навык учителя — среднее его значений по `SubjectDef.skills`; нет ключа — `Range.Min`
-- [ ] `Gain` умножает на этот фактор; учитель в кабинете по-прежнему обязателен (фаза 48)
-- [ ] Числа кривой в коде рядом с нуждой, не новый экран и не сейв
-- [ ] Лог не пишем: рост тише — это и есть сигнал
+- [x] `LessonLearning.TeacherFactor` — та же кривая, что `NeedFactor`: навык 0 → 0.25, 100 → 1
+- [x] Навык учителя — среднее его значений по `SubjectDef.skills`; нет ключа — `Range.Min`
+- [x] `Gain` умножает на этот фактор; учитель в кабинете по-прежнему обязателен (фаза 48)
+- [x] Числа кривой в коде рядом с нуждой, не новый экран и не сейв
+- [x] Лог не пишем: рост тише — это и есть сигнал
## Тесты, без которых фаза не закрыта
-- [ ] Учитель с навыком 20 даёт меньший прирост, чем с 80, при том же ученике и голоде
-- [ ] Предмет с несколькими скиллами берёт среднее, а не первый попавшийся в словаре
-- [ ] Нет ключа навыка у учителя — подставляется минимум шкалы, рост не падает в ноль
-- [ ] Учителя нет в кабинете — по-прежнему нулевой рост, фактор не спасает
+- [x] Учитель с навыком 20 даёт меньший прирост, чем с 80, при том же ученике и голоде
+- [x] Предмет с несколькими скиллами берёт среднее, а не первый попавшийся в словаре
+- [x] Нет ключа навыка у учителя — подставляется минимум шкалы, рост не падает в ноль
+- [x] Учителя нет в кабинете — по-прежнему нулевой рост, фактор не спасает
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index 4604774..af231dc 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -235,7 +235,7 @@
| --- | --- | --- |
| [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 |
+| [50. Навык учителя](50-teacher-skill-gain.md) | ✅ | Соискатель 78 учит лучше, чем 41 |
| [51. Тепло на уроке](51-warmth-lesson.md) | ⬜ | Замёрзший учится хуже, как голодный |
| [52. Учебник на уроке](52-textbook-lesson.md) | ⬜ | Нет в сумке — половинный рост |
| [53. Погода на дороге](53-weather-commute.md) | ⬜ | Снег и дождь добавляют минуты к приходу |
diff --git a/src/HSchool.Ai/LessonLearning.cs b/src/HSchool.Ai/LessonLearning.cs
index 35f0c07..f9b523b 100644
--- a/src/HSchool.Ai/LessonLearning.cs
+++ b/src/HSchool.Ai/LessonLearning.cs
@@ -3,15 +3,49 @@ using HSchool.Content;
namespace HSchool.Ai;
///
-/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets 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 learns worse; trait offsets and the
+/// teacher's subject skill scale the rate. The world stores the running total — this is just
+/// the number.
///
public static class LessonLearning
{
public static float NeedFactor(float hunger) => Math.Clamp(0.25f + (0.75f * hunger), 0.25f, 1f);
+ /// Same curve as : skill 0 → 0.25, 100 → 1.
+ public static float TeacherFactor(float skill) => NeedFactor(Math.Clamp(skill / 100f, 0f, 1f));
+
public static float TraitFactor(int offset) => Math.Max(0.1f, 1f + (offset / 100f));
+ ///
+ /// Mean of the teacher's values for . A missing key uses that
+ /// skill's minimum, not zero — a gap is "untrained", not a
+ /// vanished lesson.
+ ///
+ public static float AverageTeacherSkill(
+ SubjectDef subject,
+ IReadOnlyDictionary skills,
+ DefCatalog catalog)
+ {
+ ArgumentNullException.ThrowIfNull(subject);
+ ArgumentNullException.ThrowIfNull(skills);
+ ArgumentNullException.ThrowIfNull(catalog);
+
+ var total = 0f;
+ var count = 0;
+ foreach (var share in subject.Skills)
+ {
+ if (!catalog.Skills.TryGetValue(share.Skill, out var def) || def.Abstract)
+ {
+ continue;
+ }
+
+ total += skills.TryGetValue(share.Skill, out var value) ? value : def.Range.Min;
+ count++;
+ }
+
+ return count == 0 ? 0f : total / count;
+ }
+
public static float Gain(
float current,
SkillDef skill,
@@ -19,10 +53,16 @@ public static class LessonLearning
float lessonSkillPerHour,
float hours,
float hunger,
- int traitOffset)
+ int traitOffset,
+ float teacherSkill)
{
ArgumentNullException.ThrowIfNull(skill);
- var delta = share * lessonSkillPerHour * hours * NeedFactor(hunger) * TraitFactor(traitOffset);
+ var delta = share
+ * lessonSkillPerHour
+ * hours
+ * NeedFactor(hunger)
+ * TraitFactor(traitOffset)
+ * TeacherFactor(teacherSkill);
return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max);
}
}
diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs
index d0f1daa..684aca3 100644
--- a/src/HSchool.Simulation/LessonLearningSystem.cs
+++ b/src/HSchool.Simulation/LessonLearningSystem.cs
@@ -14,7 +14,7 @@ namespace HSchool.Simulation;
internal static class LessonLearningSystem
{
private static readonly QueryDescription Places =
- new QueryDescription().WithAll();
+ new QueryDescription().WithAll();
private static readonly QueryDescription People =
new QueryDescription().WithAll();
@@ -43,11 +43,13 @@ internal static class LessonLearningSystem
var catalog = school.Catalog;
var world = school.World;
var places = new Dictionary(StringComparer.Ordinal);
+ var teacherSkills = new Dictionary>(StringComparer.Ordinal);
world.Query(
in Places,
- (ref PersonIdentity identity, ref Presence presence) =>
+ (ref PersonIdentity identity, ref Presence presence, ref PersonSkills personSkills) =>
{
places[identity.Id] = presence;
+ teacherSkills[identity.Id] = personSkills.Values;
});
world.Query(
@@ -82,6 +84,10 @@ internal static class LessonLearningSystem
}
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
+ IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
+ ? found
+ : new Dictionary(StringComparer.Ordinal);
+ var teacherSkill = LessonLearning.AverageTeacherSkill(subject, taught, catalog);
foreach (var share in subject.Skills)
{
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
@@ -101,7 +107,8 @@ internal static class LessonLearningSystem
rules.LessonSkillPerHour,
hours,
hunger,
- TraitOffset(catalog, traits, share.Skill));
+ TraitOffset(catalog, traits, share.Skill),
+ teacherSkill);
}
});
}
diff --git a/tests/HSchool.Ai.Tests/LessonLearningTests.cs b/tests/HSchool.Ai.Tests/LessonLearningTests.cs
index cef0d5f..71f0e05 100644
--- a/tests/HSchool.Ai.Tests/LessonLearningTests.cs
+++ b/tests/HSchool.Ai.Tests/LessonLearningTests.cs
@@ -13,8 +13,8 @@ public class LessonLearningTests
[Fact]
public void HungryLearnsLessThanFull()
{
- var full = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 1f, traitOffset: 0);
- var hungry = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 0.1f, traitOffset: 0);
+ var full = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 1f, traitOffset: 0, teacherSkill: 100);
+ var hungry = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 0.1f, traitOffset: 0, teacherSkill: 100);
Assert.True(full > 50);
Assert.True(hungry > 50);
@@ -24,9 +24,60 @@ public class LessonLearningTests
[Fact]
public void DiligentOffset_RaisesTheGain()
{
- var plain = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0);
- var diligent = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 8);
+ var plain = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100);
+ var diligent = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 8, 100);
Assert.True(diligent > plain);
}
+
+ [Fact]
+ public void TeacherSkill20_GainsLessThan80()
+ {
+ var weak = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 20);
+ var strong = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 80);
+
+ Assert.True(weak > 50);
+ Assert.True(strong - 50 > weak - 50);
+ }
+
+ [Fact]
+ public void TeacherFactor_MatchesNeedCurve()
+ {
+ Assert.Equal(LessonLearning.NeedFactor(0f), LessonLearning.TeacherFactor(0f));
+ Assert.Equal(LessonLearning.NeedFactor(1f), LessonLearning.TeacherFactor(100f));
+ Assert.Equal(LessonLearning.NeedFactor(0.2f), LessonLearning.TeacherFactor(20f));
+ }
+
+ [Fact]
+ public void MultiSkillSubject_AveragesListedSkills_NotDictionaryOrder()
+ {
+ var (catalog, _) = Fixtures.Vanilla();
+ var subject = catalog.Subjects["PrimarySchool"];
+ var skills = new Dictionary(StringComparer.Ordinal)
+ {
+ ["Biology"] = 100,
+ ["Literature"] = 100,
+ ["RussianLanguage"] = 100,
+ ["Mathematics"] = 0,
+ };
+
+ var average = LessonLearning.AverageTeacherSkill(subject, skills, catalog);
+
+ Assert.Equal(75f, average);
+ Assert.NotEqual(skills.Values.First(), average);
+ }
+
+ [Fact]
+ public void MissingTeacherSkill_UsesRangeMin_AndStillGains()
+ {
+ var (catalog, _) = Fixtures.Vanilla();
+ var subject = catalog.Subjects["Mathematics"];
+ var min = catalog.Skills["Mathematics"].Range.Min;
+ var average = LessonLearning.AverageTeacherSkill(subject, new Dictionary(), catalog);
+ var gained = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, average);
+
+ Assert.Equal(min, average);
+ Assert.True(gained > 50);
+ Assert.True(gained < LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0, 100));
+ }
}
diff --git a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs
index e3fb020..adff8a0 100644
--- a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs
+++ b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs
@@ -129,6 +129,67 @@ public class LessonTeacherPresentTests
}
}
+ [Fact]
+ public void WeakTeacher_GainsLessThanStrong()
+ {
+ var (school, homeroom, pupilId, teacherId) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart);
+ SetPlace(school, pupilId, homeroom);
+ SetPlace(school, teacherId, homeroom);
+ SetSkill(school, teacherId, "Mathematics", 20);
+ var beforeWeak = SkillOf(school, pupilId, "Mathematics");
+ LessonLearningSystem.Apply(school, 45);
+ var weakGain = SkillOf(school, pupilId, "Mathematics") - beforeWeak;
+
+ SetSkill(school, pupilId, "Mathematics", beforeWeak);
+ SetSkill(school, teacherId, "Mathematics", 80);
+ var beforeStrong = SkillOf(school, pupilId, "Mathematics");
+ LessonLearningSystem.Apply(school, 45);
+ var strongGain = SkillOf(school, pupilId, "Mathematics") - beforeStrong;
+
+ Assert.True(weakGain > 0);
+ Assert.True(strongGain > weakGain);
+ }
+ }
+
+ [Fact]
+ public void MissingTeacherSkillKey_StillGains()
+ {
+ var (school, homeroom, pupilId, teacherId) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart);
+ SetPlace(school, pupilId, homeroom);
+ SetPlace(school, teacherId, homeroom);
+ ClearSkill(school, teacherId, "Mathematics");
+ var before = SkillOf(school, pupilId, "Mathematics");
+
+ LessonLearningSystem.Apply(school, 45);
+
+ Assert.True(SkillOf(school, pupilId, "Mathematics") > before);
+ }
+ }
+
+ [Fact]
+ public void TeacherSkill_DoesNotRescueAbsence()
+ {
+ var (school, homeroom, pupilId, teacherId) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart);
+ SetPlace(school, pupilId, homeroom);
+ SetPlace(school, teacherId, "restroom-1");
+ SetSkill(school, teacherId, "Mathematics", 100);
+ var before = SkillOf(school, pupilId, "Mathematics");
+
+ LessonLearningSystem.Apply(school, 45);
+
+ Assert.Equal(before, SkillOf(school, pupilId, "Mathematics"));
+ }
+ }
+
private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath(
string? teacherId = null)
{
@@ -206,6 +267,34 @@ public class LessonTeacherPresentTests
return value;
}
+ private static void SetSkill(School school, string personId, string skill, float value)
+ {
+ var query = new QueryDescription().WithAll();
+ school.World.Query(
+ in query,
+ (ref PersonIdentity identity, ref PersonSkills skills) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ skills.Values[skill] = value;
+ }
+ });
+ }
+
+ private static void ClearSkill(School school, string personId, string skill)
+ {
+ var query = new QueryDescription().WithAll();
+ school.World.Query(
+ in query,
+ (ref PersonIdentity identity, ref PersonSkills skills) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ skills.Values.Remove(skill);
+ }
+ });
+ }
+
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");