Merge branch 'phase/74-attendance'

This commit is contained in:
Leonid Pershin
2026-08-21 13:15:05 +03:00
13 changed files with 836 additions and 12 deletions
+180
View File
@@ -0,0 +1,180 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// Writes present / late / absent for each pupil lesson slot from live presence and the
/// timetable. Marks stay on stage A's path; late does not cancel them unless
/// <see cref="BehaviorDef.LessonMarkWhenLate"/> is false.
/// </summary>
internal static class AttendanceSystem
{
private static readonly QueryDescription Places =
new QueryDescription().WithAll<PersonIdentity, Presence>();
public static void Apply(School school)
{
if (school.Catalog is null || school.Timetable is null || school.Roster is null)
{
return;
}
var rules = school.Catalog.BehaviorRules;
var frame = school.Catalog.DayFrame;
if (rules is null || rules.AttendanceMax <= 0 || frame is null)
{
return;
}
var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
var previous = school.LastAttendanceSlot;
school.LastAttendanceSlot = slot;
if (previous is { Kind: DaySlotKind.Lesson } left
&& (slot.Kind != DaySlotKind.Lesson || slot.Index != left.Index))
{
FinalizeAbsents(school, left.Index, rules);
}
if (slot.Kind != DaySlotKind.Lesson)
{
return;
}
Observe(school, slot.Index, frame, rules);
}
private static void Observe(School school, int period, DayFrameDef frame, BehaviorDef rules)
{
var places = new Dictionary<string, Presence>(StringComparer.Ordinal);
school.World.Query(
in Places,
(ref PersonIdentity identity, ref Presence presence) =>
{
places[identity.Id] = presence;
});
var weekday = SchoolDay.WeekdayIndex(school.Clock.Time);
var minutesInto = MinutesIntoPeriod(school, frame, period);
var status = minutesInto <= rules.LessonLateMinutes
? AttendanceStatuses.Present
: AttendanceStatuses.Late;
foreach (var person in school.Roster!.People)
{
if (!person.IsStudent)
{
continue;
}
var lesson = CurrentLesson(school, person, weekday, period);
if (lesson is null)
{
continue;
}
if (!places.TryGetValue(person.Id, out var presence) || !IsStandingIn(presence, lesson.RoomId))
{
continue;
}
if (AttendanceMemory.Record(
person,
lesson.Subject,
status,
school.Clock.Time,
lesson.Period,
rules))
{
school.RosterTalkDirty = true;
}
}
}
private static void FinalizeAbsents(School school, int period, BehaviorDef rules)
{
var weekday = SchoolDay.WeekdayIndex(school.Clock.Time);
var day = school.Clock.Time.Date;
foreach (var person in school.Roster!.People)
{
if (!person.IsStudent)
{
continue;
}
var lesson = CurrentLesson(school, person, weekday, period);
if (lesson is null)
{
continue;
}
if (AttendanceMemory.Find(person, day, period) is not null)
{
continue;
}
if (AttendanceMemory.Record(
person,
lesson.Subject,
AttendanceStatuses.Absent,
school.Clock.Time,
period,
rules,
AbsenceReasons.Truancy))
{
school.RosterTalkDirty = true;
}
}
}
internal static double MinutesIntoPeriod(School school, DayFrameDef frame, int period)
{
var start = DateTime.SpecifyKind(
school.Clock.Time.Date.Add(SchoolDay.PeriodStart(frame, period).ToTimeSpan()),
DateTimeKind.Utc);
return (school.Clock.Time - start).TotalMinutes;
}
internal static bool IsLateForMark(School school, BehaviorDef rules, int period)
{
if (rules.LessonMarkWhenLate || school.Catalog?.DayFrame is not { } frame)
{
return false;
}
return MinutesIntoPeriod(school, frame, period) > rules.LessonLateMinutes;
}
private static bool IsStandingIn(Presence presence, string roomId) =>
presence.NodeId is not null
&& presence.Path.Length == 0
&& presence.RemainingMinutes <= 0
&& presence.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))
{
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));
}
}
+10 -1
View File
@@ -87,7 +87,11 @@ internal static class LessonLearningSystem
if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId))
{
school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTeacher, lesson.Subject);
TryWriteAbsentTeacherMark(school, person, lesson, rules);
if (!AttendanceSystem.IsLateForMark(school, rules, lesson.Period))
{
TryWriteAbsentTeacherMark(school, person, lesson, rules);
}
return;
}
@@ -174,6 +178,11 @@ internal static class LessonLearningSystem
float warmth,
float textbookFactor)
{
if (AttendanceSystem.IsLateForMark(school, rules, lesson.Period))
{
return;
}
if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period))
{
return;
+6
View File
@@ -116,6 +116,9 @@ public sealed class School : IDisposable
internal DaySlot? LastDecisionSlot { get; set; }
/// <summary>Last slot <see cref="AttendanceSystem"/> observed — detects leaving a lesson to finalize absents.</summary>
internal DaySlot? LastAttendanceSlot { get; set; }
internal Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
internal Queue<string> DecisionQueue { get; } = new();
@@ -162,6 +165,7 @@ public sealed class School : IDisposable
LoggedActivity.Clear();
_lessonLogOnce.Clear();
_lessonMarkOnce.Clear();
LastAttendanceSlot = null;
}
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
@@ -214,6 +218,7 @@ public sealed class School : IDisposable
RosterSpawner.Spawn(World, roster, Catalog);
PlanDay = null;
LastDecisionSlot = null;
LastAttendanceSlot = null;
Plans.Clear();
DecisionQueue.Clear();
LoggedActivity.Clear();
@@ -475,6 +480,7 @@ public sealed class School : IDisposable
PresenceSystem.EnqueueNewlyUrgent(this, below);
PresenceSystem.DrainDecisions(this);
LessonLearningSystem.Apply(this, gameMinutes);
AttendanceSystem.Apply(this);
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
peopleChanged |= AffinitySystem.Apply(this, talked);
}