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.
321 lines
11 KiB
C#
321 lines
11 KiB
C#
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 2–5 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;
|
||
}
|
||
}
|