diff --git a/docs/phases/48-lesson-teacher-present.md b/docs/phases/48-lesson-teacher-present.md index 4a5d233..9c19b42 100644 --- a/docs/phases/48-lesson-teacher-present.md +++ b/docs/phases/48-lesson-teacher-present.md @@ -11,25 +11,25 @@ ## Задачи -- [ ] `LessonLearningSystem` растёт навык, только если назначенный учитель стоит в +- [x] `LessonLearningSystem` растёт навык, только если назначенный учитель стоит в `lesson.RoomId`: на кампусе, путь пуст, остаток минут 0 -- [ ] Нет человека с `TeacherId` в мире — как отсутствующий -- [ ] Действие учителя урок не отменяет -- [ ] Формула `LessonLearning.Gain` не меняется -- [ ] Лог ученика `lesson-no-teacher` один раз на предмет в сутки, не каждый тик; `thingDef` — +- [x] Нет человека с `TeacherId` в мире — как отсутствующий +- [x] Действие учителя урок не отменяет +- [x] Формула `LessonLearning.Gain` не меняется +- [x] Лог ученика `lesson-no-teacher` один раз на предмет в сутки, не каждый тик; `thingDef` — предмет; подпись из каталога -- [ ] `docs/protocol.md` — новый `type` у HTTP лога, без бампа сокета -- [ ] Фикстуры, где учитель был строкой `t1` вне ростера, нанимают живого человека — иначе +- [x] `docs/protocol.md` — новый `type` у HTTP лога, без бампа сокета +- [x] Фикстуры, где учитель был строкой `t1` вне ростера, нанимают живого человека — иначе существующий тест роста навыка зелёный впустую ## Тесты, без которых фаза не закрыта -- [ ] Ученик и учитель стоят в кабинете → навык за урок вырос -- [ ] Ученик в кабинете, учитель в другом узле → навык не вырос -- [ ] Учитель идёт по коридору в этот кабинет → пока идёт, роста нет -- [ ] Нет человека с id учителя → роста нет -- [ ] За урок без учителя в логе ученика одна строка `lesson-no-teacher`, не по числу тиков -- [ ] Голодный по-прежнему учится хуже сытого, когда учитель на месте +- [x] Ученик и учитель стоят в кабинете → навык за урок вырос +- [x] Ученик в кабинете, учитель в другом узле → навык не вырос +- [x] Учитель идёт по коридору в этот кабинет → пока идёт, роста нет +- [x] Нет человека с id учителя → роста нет +- [x] За урок без учителя в логе ученика одна строка `lesson-no-teacher`, не по числу тиков +- [x] Голодный по-прежнему учится хуже сытого, когда учитель на месте ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 1df2394..1359a27 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -415,8 +415,9 @@ substring of the caption or type. `sort` is `time`. `dir` is `asc` or `desc` (de 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). `thingDef` is -the action or apparel def the caption was built from. +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. ```json { diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index c587ff1..e6ece25 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -191,5 +191,6 @@ "ActionEnded": "finished: {0}", "ApparelReplaced": "got a new {0}", "ApparelChanged": "changed clothes: {0}", + "LessonNoTeacher": "lesson without a teacher: {0}", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index affb2e6..28ce406 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -191,5 +191,6 @@ "ActionEnded": "закончил: {0}", "ApparelReplaced": "получил новую {0}", "ApparelChanged": "переоделся: {0}", + "LessonNoTeacher": "урок без учителя: {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index f1c1e88..d0f1daa 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -8,10 +8,14 @@ namespace HSchool.Simulation; /// /// Grows skills for people who are actually in the lesson: at the room, not walking, not off -/// doing something else. The formula lives in . +/// doing something else, and with the assigned teacher standing there. The formula lives in +/// . /// internal static class LessonLearningSystem { + private static readonly QueryDescription Places = + new QueryDescription().WithAll(); + private static readonly QueryDescription People = new QueryDescription().WithAll(); @@ -38,11 +42,19 @@ internal static class LessonLearningSystem var weekday = SchoolDay.WeekdayIndex(school.Clock.Time); var catalog = school.Catalog; var world = school.World; + var places = new Dictionary(StringComparer.Ordinal); + world.Query( + in Places, + (ref PersonIdentity identity, ref Presence presence) => + { + places[identity.Id] = presence; + }); + world.Query( in People, (ref PersonIdentity identity, ref PersonSkills skills, ref PersonTraits traits, ref PersonNeeds needs, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => { - if (activity.IsActive || presence.NodeId is null || presence.Path.Length > 0 || presence.RemainingMinutes > 0) + if (activity.IsActive || !IsStanding(presence)) { return; } @@ -56,12 +68,19 @@ internal static class LessonLearningSystem var lesson = CurrentLesson(school, person, weekday, slot.Index); if (lesson is null + || presence.NodeId is null || !presence.NodeId.Equals(lesson.RoomId, StringComparison.Ordinal) || !catalog.Subjects.TryGetValue(lesson.Subject, out var subject)) { return; } + if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId)) + { + school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTeacher, lesson.Subject); + return; + } + var hunger = needs.Values.GetValueOrDefault("Hunger", 1f); foreach (var share in subject.Skills) { @@ -87,6 +106,17 @@ internal static class LessonLearningSystem }); } + private static bool IsStanding(Presence presence) => + presence.NodeId is not null && presence.Path.Length == 0 && presence.RemainingMinutes <= 0; + + private static bool TeacherStandingIn( + Dictionary places, + string teacherId, + string roomId) => + places.TryGetValue(teacherId, out var teacher) && IsStanding(teacher) + && teacher.NodeId is not null + && teacher.NodeId.Equals(roomId, StringComparison.Ordinal); + private static LessonPlacement? CurrentLesson(School school, Person person, int weekday, int period) { foreach (var lesson in Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday)) diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 24534b1..8a4c75b 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -14,6 +14,7 @@ public static class PersonLogTypes public const string ActionEnded = "action-ended"; public const string ApparelReplaced = "apparel-replaced"; public const string ApparelChanged = "apparel-changed"; + public const string LessonNoTeacher = "lesson-no-teacher"; } /// @@ -58,6 +59,14 @@ 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)) + { + var name = catalog.Subjects.TryGetValue(ThingDef, out var subject) + ? catalog.Label(locale, subject) + : ThingDef; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "LessonNoTeacher"), name); + } + return Type; } } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index bfcd068..679504f 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -17,6 +17,7 @@ public sealed class School : IDisposable private bool _disposed; private readonly List _dayLog = []; + private readonly HashSet _lessonLogOnce = new(StringComparer.Ordinal); internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map) { @@ -135,10 +136,26 @@ public sealed class School : IDisposable { _dayLog.Clear(); LoggedActivity.Clear(); + _lessonLogOnce.Clear(); } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); + /// + /// Lesson-quality rows fire every tick the condition holds. One key per person per day is enough. + /// + internal bool TryLogLessonOnce(string personId, string type, string subject) + { + var key = string.Concat(personId, "\0", type, "\0", subject); + if (!_lessonLogOnce.Add(key)) + { + return false; + } + + AppendDayLog(new PersonLogEvent(personId, Clock.Time, type, subject)); + return true; + } + public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/tests/HSchool.Simulation.Tests/DecisionTests.cs b/tests/HSchool.Simulation.Tests/DecisionTests.cs index ffdb2e5..fcb967c 100644 --- a/tests/HSchool.Simulation.Tests/DecisionTests.cs +++ b/tests/HSchool.Simulation.Tests/DecisionTests.cs @@ -195,13 +195,18 @@ public class DecisionTests { var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", TuesdayMorning); var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", TuesdayMorning); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f); + Assert.Equal(StaffingError.None, hired.Error); + roster = hired.Roster; + pool = hired.Pool; + var teacherId = roster.People.First(person => person.IsStaff).Id; var schoolClass = roster.Classes.First(row => row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); var school = School.Create(seed, "Решения", TuesdayMorning, catalog, map); school.InstallPeople(roster, seed, "Russia", pool); school.SetTimetable(new Timetable( [ - new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), + new LessonPlacement(schoolClass.Id, "Mathematics", teacherId, schoolClass.RoomId, Day: 1, Period: 1), new LessonPlacement(schoolClass.Id, "PhysicalEducation", "t2", "gym-hall", Day: 1, Period: 2), ], [])); diff --git a/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs new file mode 100644 index 0000000..e3fb020 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/LessonTeacherPresentTests.cs @@ -0,0 +1,230 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class LessonTeacherPresentTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc); + + [Fact] + public void PupilAndTeacherStanding_GainsSkill() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, homeroom); + var before = SkillOf(school, pupilId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + Assert.True(SkillOf(school, pupilId, "Mathematics") > before); + } + } + + [Fact] + public void TeacherInAnotherRoom_DoesNotGain() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, "restroom-1"); + var before = SkillOf(school, pupilId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + Assert.Equal(before, SkillOf(school, pupilId, "Mathematics")); + } + } + + [Fact] + public void TeacherWalking_DoesNotGain() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, "corridor-1", path: [homeroom], remaining: 1.5f); + var before = SkillOf(school, pupilId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + Assert.Equal(before, SkillOf(school, pupilId, "Mathematics")); + } + } + + [Fact] + public void MissingTeacherId_DoesNotGain() + { + var (school, homeroom, pupilId, _) = StaffedMath(teacherId: "nobody"); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + var before = SkillOf(school, pupilId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + Assert.Equal(before, SkillOf(school, pupilId, "Mathematics")); + } + } + + [Fact] + public void NoTeacher_LogsOncePerSubject() + { + var (school, homeroom, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, homeroom); + SetPlace(school, teacherId, "restroom-1"); + + LessonLearningSystem.Apply(school, 5); + LessonLearningSystem.Apply(school, 5); + + var rows = school.DayLog + .Where(row => row.PersonId == pupilId && row.Type == PersonLogTypes.LessonNoTeacher) + .ToArray(); + Assert.Single(rows); + Assert.Equal("Mathematics", rows[0].ThingDef); + Assert.Equal( + "урок без учителя: Математика", + rows[0].Caption(school.Catalog!, "ru")); + } + } + + [Fact] + public void HungryStillGainsLess_WhenTeacherIsPresent() + { + var (school, homeroom, firstId, teacherId) = StaffedMath(); + using (school) + { + var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Contains(firstId)); + var secondId = schoolClass.PupilIds.First(id => id != firstId); + AdvanceTo(school, LessonStart); + SetPlace(school, firstId, homeroom); + SetPlace(school, secondId, homeroom); + SetPlace(school, teacherId, homeroom); + SetNeed(school, firstId, "Hunger", 0.2f); + SetNeed(school, secondId, "Hunger", 1f); + var hungryBefore = SkillOf(school, firstId, "Mathematics"); + var fullBefore = SkillOf(school, secondId, "Mathematics"); + + LessonLearningSystem.Apply(school, 45); + + var hungryGain = SkillOf(school, firstId, "Mathematics") - hungryBefore; + var fullGain = SkillOf(school, secondId, "Mathematics") - fullBefore; + Assert.True(fullGain > 0); + Assert.True(hungryGain > 0); + Assert.True(fullGain > hungryGain); + } + } + + private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath( + string? teacherId = null) + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f); + Assert.Equal(StaffingError.None, hired.Error); + roster = hired.Roster; + pool = hired.Pool; + var hiredId = roster.People.First(person => person.IsStaff).Id; + var schoolClass = roster.Classes.First(row => + row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); + var school = School.Create(1, "Урок", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.SetTimetable(new Timetable( + [new LessonPlacement(schoolClass.Id, "Mathematics", teacherId ?? hiredId, schoolClass.RoomId, Day: 1, Period: 1)], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + var pupil = schoolClass.PupilIds + .Select(id => school.Roster!.People.First(person => person.Id == id)) + .First(person => !person.Traits.Contains("Lazy")); + return (school, schoolClass.RoomId, pupil.Id, hiredId); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static void SetPlace(School school, string personId, string node, string[]? path = null, float remaining = 0f) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + presence = new Presence(node, remaining, node, HeadingHome: false, path ?? []); + } + }); + } + + private static void SetNeed(School school, string personId, string need, float value) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonNeeds needs) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + needs.Values[need] = value; + } + }); + } + + private static float SkillOf(School school, string personId, string skill) + { + var value = float.NaN; + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonSkills skills) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + value = skills.Values.GetValueOrDefault(skill); + } + }); + return value; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}