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");