Scale lesson skill gain by the teacher's subject skill.
Hiring a 78-math applicant already cost more than a 41; the class now actually learns faster when that teacher is in the room. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,18 +11,18 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `LessonLearning.TeacherFactor` — та же кривая, что `NeedFactor`: навык 0 → 0.25, 100 → 1
|
- [x] `LessonLearning.TeacherFactor` — та же кривая, что `NeedFactor`: навык 0 → 0.25, 100 → 1
|
||||||
- [ ] Навык учителя — среднее его значений по `SubjectDef.skills`; нет ключа — `Range.Min`
|
- [x] Навык учителя — среднее его значений по `SubjectDef.skills`; нет ключа — `Range.Min`
|
||||||
- [ ] `Gain` умножает на этот фактор; учитель в кабинете по-прежнему обязателен (фаза 48)
|
- [x] `Gain` умножает на этот фактор; учитель в кабинете по-прежнему обязателен (фаза 48)
|
||||||
- [ ] Числа кривой в коде рядом с нуждой, не новый экран и не сейв
|
- [x] Числа кривой в коде рядом с нуждой, не новый экран и не сейв
|
||||||
- [ ] Лог не пишем: рост тише — это и есть сигнал
|
- [x] Лог не пишем: рост тише — это и есть сигнал
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Учитель с навыком 20 даёт меньший прирост, чем с 80, при том же ученике и голоде
|
- [x] Учитель с навыком 20 даёт меньший прирост, чем с 80, при том же ученике и голоде
|
||||||
- [ ] Предмет с несколькими скиллами берёт среднее, а не первый попавшийся в словаре
|
- [x] Предмет с несколькими скиллами берёт среднее, а не первый попавшийся в словаре
|
||||||
- [ ] Нет ключа навыка у учителя — подставляется минимум шкалы, рост не падает в ноль
|
- [x] Нет ключа навыка у учителя — подставляется минимум шкалы, рост не падает в ноль
|
||||||
- [ ] Учителя нет в кабинете — по-прежнему нулевой рост, фактор не спасает
|
- [x] Учителя нет в кабинете — по-прежнему нулевой рост, фактор не спасает
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,49 @@ using HSchool.Content;
|
|||||||
namespace HSchool.Ai;
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets scale the
|
/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets and the
|
||||||
/// rate. The world stores the running total — this is just the number.
|
/// teacher's subject skill scale the rate. The world stores the running total — this is just
|
||||||
|
/// the number.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class LessonLearning
|
public static class LessonLearning
|
||||||
{
|
{
|
||||||
public static float NeedFactor(float hunger) => Math.Clamp(0.25f + (0.75f * hunger), 0.25f, 1f);
|
public static float NeedFactor(float hunger) => Math.Clamp(0.25f + (0.75f * hunger), 0.25f, 1f);
|
||||||
|
|
||||||
|
/// <summary>Same curve as <see cref="NeedFactor"/>: skill 0 → 0.25, 100 → 1.</summary>
|
||||||
|
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));
|
public static float TraitFactor(int offset) => Math.Max(0.1f, 1f + (offset / 100f));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mean of the teacher's values for <see cref="SubjectDef.Skills"/>. A missing key uses that
|
||||||
|
/// skill's <see cref="SkillDef.Range"/> minimum, not zero — a gap is "untrained", not a
|
||||||
|
/// vanished lesson.
|
||||||
|
/// </summary>
|
||||||
|
public static float AverageTeacherSkill(
|
||||||
|
SubjectDef subject,
|
||||||
|
IReadOnlyDictionary<string, float> 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(
|
public static float Gain(
|
||||||
float current,
|
float current,
|
||||||
SkillDef skill,
|
SkillDef skill,
|
||||||
@@ -19,10 +53,16 @@ public static class LessonLearning
|
|||||||
float lessonSkillPerHour,
|
float lessonSkillPerHour,
|
||||||
float hours,
|
float hours,
|
||||||
float hunger,
|
float hunger,
|
||||||
int traitOffset)
|
int traitOffset,
|
||||||
|
float teacherSkill)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(skill);
|
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);
|
return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace HSchool.Simulation;
|
|||||||
internal static class LessonLearningSystem
|
internal static class LessonLearningSystem
|
||||||
{
|
{
|
||||||
private static readonly QueryDescription Places =
|
private static readonly QueryDescription Places =
|
||||||
new QueryDescription().WithAll<PersonIdentity, Presence>();
|
new QueryDescription().WithAll<PersonIdentity, Presence, PersonSkills>();
|
||||||
|
|
||||||
private static readonly QueryDescription People =
|
private static readonly QueryDescription People =
|
||||||
new QueryDescription().WithAll<PersonIdentity, PersonSkills, PersonTraits, PersonNeeds, PersonRoles, Presence, PersonActivity>();
|
new QueryDescription().WithAll<PersonIdentity, PersonSkills, PersonTraits, PersonNeeds, PersonRoles, Presence, PersonActivity>();
|
||||||
@@ -43,11 +43,13 @@ internal static class LessonLearningSystem
|
|||||||
var catalog = school.Catalog;
|
var catalog = school.Catalog;
|
||||||
var world = school.World;
|
var world = school.World;
|
||||||
var places = new Dictionary<string, Presence>(StringComparer.Ordinal);
|
var places = new Dictionary<string, Presence>(StringComparer.Ordinal);
|
||||||
|
var teacherSkills = new Dictionary<string, Dictionary<string, float>>(StringComparer.Ordinal);
|
||||||
world.Query(
|
world.Query(
|
||||||
in Places,
|
in Places,
|
||||||
(ref PersonIdentity identity, ref Presence presence) =>
|
(ref PersonIdentity identity, ref Presence presence, ref PersonSkills personSkills) =>
|
||||||
{
|
{
|
||||||
places[identity.Id] = presence;
|
places[identity.Id] = presence;
|
||||||
|
teacherSkills[identity.Id] = personSkills.Values;
|
||||||
});
|
});
|
||||||
|
|
||||||
world.Query(
|
world.Query(
|
||||||
@@ -82,6 +84,10 @@ internal static class LessonLearningSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
|
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
|
||||||
|
IReadOnlyDictionary<string, float> taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
|
||||||
|
? found
|
||||||
|
: new Dictionary<string, float>(StringComparer.Ordinal);
|
||||||
|
var teacherSkill = LessonLearning.AverageTeacherSkill(subject, taught, catalog);
|
||||||
foreach (var share in subject.Skills)
|
foreach (var share in subject.Skills)
|
||||||
{
|
{
|
||||||
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
|
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
|
||||||
@@ -101,7 +107,8 @@ internal static class LessonLearningSystem
|
|||||||
rules.LessonSkillPerHour,
|
rules.LessonSkillPerHour,
|
||||||
hours,
|
hours,
|
||||||
hunger,
|
hunger,
|
||||||
TraitOffset(catalog, traits, share.Skill));
|
TraitOffset(catalog, traits, share.Skill),
|
||||||
|
teacherSkill);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ public class LessonLearningTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void HungryLearnsLessThanFull()
|
public void HungryLearnsLessThanFull()
|
||||||
{
|
{
|
||||||
var full = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 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);
|
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(full > 50);
|
||||||
Assert.True(hungry > 50);
|
Assert.True(hungry > 50);
|
||||||
@@ -24,9 +24,60 @@ public class LessonLearningTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void DiligentOffset_RaisesTheGain()
|
public void DiligentOffset_RaisesTheGain()
|
||||||
{
|
{
|
||||||
var plain = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0);
|
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);
|
var diligent = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 8, 100);
|
||||||
|
|
||||||
Assert.True(diligent > plain);
|
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<string, float>(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<string, float>(), 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath(
|
||||||
string? teacherId = null)
|
string? teacherId = null)
|
||||||
{
|
{
|
||||||
@@ -206,6 +267,34 @@ public class LessonTeacherPresentTests
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void SetSkill(School school, string personId, string skill, float value)
|
||||||
|
{
|
||||||
|
var query = new QueryDescription().WithAll<PersonIdentity, PersonSkills>();
|
||||||
|
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<PersonIdentity, PersonSkills>();
|
||||||
|
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()
|
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
|||||||
Reference in New Issue
Block a user