using Arch.Core; using HSchool.Ai; using HSchool.Content; using HSchool.People; using HSchool.Schedule; 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. A pupil without today's /// textbook in the bag learns at . Writes one /// mark 2–5 per lesson slot from the same quality inputs. The formula lives in /// . /// internal static class LessonLearningSystem { private static readonly QueryDescription Places = new QueryDescription().WithAll(); private static readonly QueryDescription People = new QueryDescription().WithAll(); public static void Apply(School school, double gameMinutes) { if (school.Catalog is null || school.Timetable is null || school.Roster is null || gameMinutes <= 0) { return; } var rules = school.Catalog.BehaviorRules; if (rules is null || rules.LessonSkillPerHour <= 0) { return; } var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays); if (slot.Kind != DaySlotKind.Lesson) { return; } var hours = (float)(gameMinutes / 60d); var weekday = SchoolDay.WeekdayIndex(school.Clock.Time); 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 PersonSkills personSkills) => { places[identity.Id] = presence; teacherSkills[identity.Id] = personSkills.Values; }); RaiseSickTeacherLessons(school, places, weekday, slot.Index); 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 (!IsStanding(presence)) { return; } if (activity.IsActive && !TalkActions.IsWhisper(activity.ActionId)) { return; } var personId = identity.Id; var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal)); if (person is null || !person.IsStudent) { return; } 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); if (!AttendanceSystem.IsLateForMark(school, rules, lesson.Period)) { TryWriteAbsentTeacherMark(school, person, lesson, rules); } return; } var hunger = needs.Values.GetValueOrDefault("Hunger", 1f); var warmth = needs.Values.GetValueOrDefault("Warmth", 1f); if (warmth < rules.NeedThreshold) { school.TryLogLessonOnce(personId, PersonLogTypes.LessonCold, lesson.Subject); } var textbookFactor = 1f; if (!LessonLearning.HasTextbookInBag(person.Items, lesson.Subject)) { textbookFactor = rules.LessonNoTextbookFactor; school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTextbook, lesson.Subject); } textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules); textbookFactor *= DiseaseEffects.LessonLearningFactor(person, catalog, school.Clock.Time); IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found : new Dictionary(StringComparer.Ordinal); var teacherSkill = LessonLearning.AverageTeacherSkill(subject, taught, catalog); var traitOffset = AverageTraitOffset(catalog, traits, subject); foreach (var share in subject.Skills) { if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract) { continue; } if (!skills.Values.TryGetValue(share.Skill, out var current)) { current = skill.Range.Min; } skills.Values[share.Skill] = LessonLearning.Gain( current, skill, share.Share, rules.LessonSkillPerHour, hours, hunger, TraitOffset(catalog, traits, share.Skill), teacherSkill, warmth, textbookFactor); } TryWriteQualityMark(school, person, lesson, rules, hunger, traitOffset, teacherSkill, warmth, textbookFactor); }); } private static void TryWriteAbsentTeacherMark( School school, Person person, LessonPlacement lesson, BehaviorDef rules) { if (rules.LessonMarkWhenNoTeacher is not { } mark) { return; } if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period)) { return; } if (LessonMarkMemory.Record(person, lesson.Subject, mark, school.Clock.Time, lesson.Period, rules)) { school.RosterTalkDirty = true; } } private static void TryWriteQualityMark( School school, Person person, LessonPlacement lesson, BehaviorDef rules, float hunger, int traitOffset, float teacherSkill, float warmth, float textbookFactor) { if (AttendanceSystem.IsLateForMark(school, rules, lesson.Period)) { return; } if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period)) { return; } var quality = LessonLearning.Quality(hunger, traitOffset, teacherSkill, warmth, textbookFactor); var mark = LessonLearning.Mark(quality, rules); if (LessonMarkMemory.Record(person, lesson.Subject, mark, school.Clock.Time, lesson.Period, rules)) { school.RosterTalkDirty = true; } } 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); /// /// One school toast per class slot when the assigned teacher stayed home sick (slice 15). /// Uses the morning day plan — re-rolling at /// lesson time can flip after severity ticks. Toilet trips keep the per-pupil log only. /// private static void RaiseSickTeacherLessons( School school, Dictionary places, int weekday, int period) { if (school.Timetable is null || school.Roster is null || school.Catalog is null) { return; } foreach (var lesson in school.Timetable.Lessons) { if (lesson.Day != weekday || lesson.Period != period) { continue; } if (TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId)) { continue; } if (!school.Plans.TryGetValue(lesson.TeacherId, out var plan) || plan.Comes) { continue; } var teacher = school.Roster.People.FirstOrDefault(row => row.Id.Equals(lesson.TeacherId, StringComparison.Ordinal)); if (teacher is null || DiseaseEffects.StayHomeChance(teacher, school.Catalog, school.Clock.Time) <= 0f) { continue; } if (!school.TryClaimLessonNoTeacherEvent(lesson.ClassId, school.Clock.Time.Date, lesson.Period)) { continue; } school.RaiseWorldEvent(new WorldEvent(EventTriggers.LessonNoTeacher, lesson.TeacherId)); } } 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)) { if (lesson.Period == period) { return lesson; } } return null; } private static SchoolClass? ClassOf(School school, Person person) { if (person.ClassId is null) { return null; } return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal)); } private static int AverageTraitOffset(DefCatalog catalog, PersonTraits traits, SubjectDef subject) { var total = 0; var count = 0; foreach (var share in subject.Skills) { total += TraitOffset(catalog, traits, share.Skill); count++; } return count == 0 ? 0 : total / count; } private static int TraitOffset(DefCatalog catalog, PersonTraits traits, string skill) { var offset = 0; foreach (var name in traits.Ids) { if (!catalog.Traits.TryGetValue(name, out var trait)) { continue; } foreach (var modifier in trait.SkillModifiers) { if (modifier.Skill.Equals(skill, StringComparison.Ordinal)) { offset += modifier.Offset; } } } return offset; } }