Merge branch 'phase/74-attendance'
This commit is contained in:
@@ -722,6 +722,17 @@ internal static class PeopleDefValidator
|
||||
previous = floor;
|
||||
}
|
||||
}
|
||||
|
||||
if (behavior.AttendanceMax < 0)
|
||||
{
|
||||
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' attendanceMax cannot be negative.");
|
||||
}
|
||||
|
||||
if (behavior.LessonLateMinutes < 0f)
|
||||
{
|
||||
throw new ContentLoadException(
|
||||
$"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
|
||||
|
||||
@@ -619,6 +619,23 @@ public sealed class BehaviorDef : Def
|
||||
/// </summary>
|
||||
public int? LessonMarkWhenNoTeacher { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// How many recent attendance rows a person keeps. Oldest drop first. Zero disables attendance.
|
||||
/// </summary>
|
||||
public int AttendanceMax { get; init; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes after the bell: first standing in the room within this window is present;
|
||||
/// later is late. Inclusive — arrival at exactly this many minutes still counts as present.
|
||||
/// </summary>
|
||||
public float LessonLateMinutes { get; init; } = 5f;
|
||||
|
||||
/// <summary>
|
||||
/// When true (vanilla), a late arrival still receives a lesson mark if they are standing in
|
||||
/// the room — stage A quality path. False skips the mark for late pupils.
|
||||
/// </summary>
|
||||
public bool LessonMarkWhenLate { get; init; } = true;
|
||||
|
||||
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
||||
[
|
||||
0.85f,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>Stable status ids written into <see cref="AttendanceRecord.Status"/>.</summary>
|
||||
public static class AttendanceStatuses
|
||||
{
|
||||
public const string Present = "present";
|
||||
|
||||
public const string Late = "late";
|
||||
|
||||
public const string Absent = "absent";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Why a pupil was absent. Vanilla writes <see cref="Truancy"/>; slice 14 fills illness.
|
||||
/// </summary>
|
||||
public static class AbsenceReasons
|
||||
{
|
||||
public const string Truancy = "truancy";
|
||||
}
|
||||
|
||||
/// <summary>One lesson-slot attendance row. Sparse list on the person — not a year journal.</summary>
|
||||
public sealed class AttendanceRecord
|
||||
{
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary><see cref="AttendanceStatuses"/> id.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
public required DateTime Time { get; init; }
|
||||
|
||||
public required int Period { get; init; }
|
||||
|
||||
/// <summary>Set when <see cref="Status"/> is absent. Omitted for present/late.</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? AbsenceReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Appends or upgrades recent attendance and drops the oldest when over the BehaviorDef ceiling.</summary>
|
||||
public static class AttendanceMemory
|
||||
{
|
||||
public static AttendanceRecord? Find(Person person, DateTime day, int period)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(person);
|
||||
var list = person.Attendance;
|
||||
if (list is null || list.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var date = DateTime.SpecifyKind(day, DateTimeKind.Utc).Date;
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var row = list[i];
|
||||
if (row.Period == period && row.Time.Date == date)
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a new row, or upgrades an existing absent row to present/late. Present/late once
|
||||
/// claimed for the slot stay put — first arrival wins.
|
||||
/// </summary>
|
||||
public static bool Record(
|
||||
Person person,
|
||||
string subject,
|
||||
string status,
|
||||
DateTime time,
|
||||
int period,
|
||||
BehaviorDef rules,
|
||||
string? absenceReason = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(person);
|
||||
ArgumentNullException.ThrowIfNull(rules);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subject);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||
|
||||
if (rules.AttendanceMax <= 0 || !IsKnownStatus(status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var list = person.Attendance;
|
||||
if (list is null)
|
||||
{
|
||||
list = [];
|
||||
person.Attendance = list;
|
||||
}
|
||||
|
||||
var date = DateTime.SpecifyKind(time, DateTimeKind.Utc).Date;
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var existing = list[i];
|
||||
if (existing.Period != period || existing.Time.Date != date)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.Status is AttendanceStatuses.Present or AttendanceStatuses.Late)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existing.Status == AttendanceStatuses.Absent
|
||||
&& status is AttendanceStatuses.Present or AttendanceStatuses.Late)
|
||||
{
|
||||
list[i] = Build(subject, status, time, period, absenceReason: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
list.Add(Build(
|
||||
subject,
|
||||
status,
|
||||
time,
|
||||
period,
|
||||
status == AttendanceStatuses.Absent
|
||||
? (string.IsNullOrWhiteSpace(absenceReason) ? AbsenceReasons.Truancy : absenceReason)
|
||||
: null));
|
||||
|
||||
while (list.Count > rules.AttendanceMax)
|
||||
{
|
||||
list.RemoveAt(0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AttendanceRecord Build(
|
||||
string subject,
|
||||
string status,
|
||||
DateTime time,
|
||||
int period,
|
||||
string? absenceReason) =>
|
||||
new()
|
||||
{
|
||||
Subject = subject,
|
||||
Status = status,
|
||||
Time = DateTime.SpecifyKind(time, DateTimeKind.Utc),
|
||||
Period = period,
|
||||
AbsenceReason = absenceReason,
|
||||
};
|
||||
|
||||
private static bool IsKnownStatus(string status) =>
|
||||
status is AttendanceStatuses.Present or AttendanceStatuses.Late or AttendanceStatuses.Absent;
|
||||
}
|
||||
@@ -81,6 +81,12 @@ public sealed record Person
|
||||
/// </summary>
|
||||
public List<LessonMarkRecord>? LessonMarks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recent lesson attendance (present / late / absent). Null when empty so people.json stays compact.
|
||||
/// Ceiling and late threshold live on <c>BehaviorDef</c>.
|
||||
/// </summary>
|
||||
public List<AttendanceRecord>? Attendance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active medical conditions. Null when healthy (empty) so people.json stays compact.
|
||||
/// Needs are not replaced by this list.
|
||||
|
||||
@@ -110,4 +110,10 @@
|
||||
"lessonMarkThresholds": [0.85, 0.6, 0.35],
|
||||
// Teacher not in the room: write 2 (null would skip the mark entirely).
|
||||
"lessonMarkWhenNoTeacher": 2,
|
||||
// Attendance from presence (slice 13 phase 74). One row per slot.
|
||||
"attendanceMax": 40,
|
||||
// First standing in the room within this many minutes after the bell → present; later → late.
|
||||
"lessonLateMinutes": 5,
|
||||
// Late still gets a mark when standing (stage A). False would skip the mark for late arrivals.
|
||||
"lessonMarkWhenLate": true,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user