Files
h-school/src/HSchool.Simulation/LessonLearningSystem.cs
T
Leonid Pershin b314f50112 Wire sick-teacher stay-home into cancelled lessons.
Heavy flu keeps the assigned teacher off the day plan; pupils get 48/73 no-teacher marks and a one-shot LessonCancelled notice instead of silent empty rooms.
2026-08-21 14:17:37 +03:00

321 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// 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 <see cref="BehaviorDef.LessonNoTextbookFactor"/>. Writes one
/// mark 25 per lesson slot from the same quality inputs. The formula lives in
/// <see cref="HSchool.Ai.LessonLearning"/>.
/// </summary>
internal static class LessonLearningSystem
{
private static readonly QueryDescription Places =
new QueryDescription().WithAll<PersonIdentity, Presence, PersonSkills>();
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonSkills, PersonTraits, PersonNeeds, PersonRoles, Presence, PersonActivity>();
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<string, Presence>(StringComparer.Ordinal);
var teacherSkills = new Dictionary<string, Dictionary<string, float>>(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<string, float> taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
? found
: new Dictionary<string, float>(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<string, Presence> places,
string teacherId,
string roomId) =>
places.TryGetValue(teacherId, out var teacher) && IsStanding(teacher)
&& teacher.NodeId is not null
&& teacher.NodeId.Equals(roomId, StringComparison.Ordinal);
/// <summary>
/// One school toast per class slot when the assigned teacher stayed home sick (slice 15).
/// Uses the morning day plan — re-rolling <see cref="DiseaseEffects.ShouldStayHome"/> at
/// lesson time can flip after severity ticks. Toilet trips keep the per-pupil log only.
/// </summary>
private static void RaiseSickTeacherLessons(
School school,
Dictionary<string, Presence> 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;
}
}