diff --git a/docs/phases/13-grades/74-attendance.md b/docs/phases/13-grades/74-attendance.md
index d1dcc0a..22640c5 100644
--- a/docs/phases/13-grades/74-attendance.md
+++ b/docs/phases/13-grades/74-attendance.md
@@ -11,20 +11,20 @@
## Задачи
-- [ ] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в
+- [x] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в
данных), отсутствовал
-- [ ] Писать в сейв рядом с журналом оценок
-- [ ] Источник — присутствие и расписание, не клиент
-- [ ] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14
-- [ ] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных
+- [x] Писать в сейв рядом с журналом оценок
+- [x] Источник — присутствие и расписание, не клиент
+- [x] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14
+- [x] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных
## Тесты, без которых фаза не закрыта
-- [ ] Ученик в кабинете к началу → присутствовал
-- [ ] Пришёл после порога → опоздал
-- [ ] Вне школы / другой узел весь слот → отсутствовал
-- [ ] Тот же день и сид → та же явка
-- [ ] Сейв переживает reload
+- [x] Ученик в кабинете к началу → присутствовал
+- [x] Пришёл после порога → опоздал
+- [x] Вне школы / другой узел весь слот → отсутствовал
+- [x] Тот же день и сид → та же явка
+- [x] Сейв переживает reload
## Критерий готовности
diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs
index 4fb0aed..ffdcb28 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -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)
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index b65078a..29fa813 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -619,6 +619,23 @@ public sealed class BehaviorDef : Def
///
public int? LessonMarkWhenNoTeacher { get; init; } = 2;
+ ///
+ /// How many recent attendance rows a person keeps. Oldest drop first. Zero disables attendance.
+ ///
+ public int AttendanceMax { get; init; } = 40;
+
+ ///
+ /// 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.
+ ///
+ public float LessonLateMinutes { get; init; } = 5f;
+
+ ///
+ /// 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.
+ ///
+ public bool LessonMarkWhenLate { get; init; } = true;
+
public static IReadOnlyList DefaultLessonMarkThresholds { get; } =
[
0.85f,
diff --git a/src/HSchool.People/Attendance.cs b/src/HSchool.People/Attendance.cs
new file mode 100644
index 0000000..3b3075a
--- /dev/null
+++ b/src/HSchool.People/Attendance.cs
@@ -0,0 +1,154 @@
+using System.Text.Json.Serialization;
+using HSchool.Content;
+
+namespace HSchool.People;
+
+/// Stable status ids written into .
+public static class AttendanceStatuses
+{
+ public const string Present = "present";
+
+ public const string Late = "late";
+
+ public const string Absent = "absent";
+}
+
+///
+/// Why a pupil was absent. Vanilla writes ; slice 14 fills illness.
+///
+public static class AbsenceReasons
+{
+ public const string Truancy = "truancy";
+}
+
+/// One lesson-slot attendance row. Sparse list on the person — not a year journal.
+public sealed class AttendanceRecord
+{
+ public required string Subject { get; init; }
+
+ /// id.
+ public required string Status { get; init; }
+
+ public required DateTime Time { get; init; }
+
+ public required int Period { get; init; }
+
+ /// Set when is absent. Omitted for present/late.
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? AbsenceReason { get; init; }
+}
+
+/// Appends or upgrades recent attendance and drops the oldest when over the BehaviorDef ceiling.
+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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+}
diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs
index ce79764..c2abedb 100644
--- a/src/HSchool.People/Roster.cs
+++ b/src/HSchool.People/Roster.cs
@@ -81,6 +81,12 @@ public sealed record Person
///
public List? LessonMarks { get; set; }
+ ///
+ /// Recent lesson attendance (present / late / absent). Null when empty so people.json stays compact.
+ /// Ceiling and late threshold live on BehaviorDef.
+ ///
+ public List? Attendance { get; set; }
+
///
/// Active medical conditions. Null when healthy (empty) so people.json stays compact.
/// Needs are not replaced by this list.
diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
index d03d435..4874084 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -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,
}
diff --git a/src/HSchool.Simulation/AttendanceSystem.cs b/src/HSchool.Simulation/AttendanceSystem.cs
new file mode 100644
index 0000000..dc81974
--- /dev/null
+++ b/src/HSchool.Simulation/AttendanceSystem.cs
@@ -0,0 +1,180 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+///
+/// 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
+/// is false.
+///
+internal static class AttendanceSystem
+{
+ private static readonly QueryDescription Places =
+ new QueryDescription().WithAll();
+
+ 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(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));
+ }
+}
diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs
index 19c3780..bba899a 100644
--- a/src/HSchool.Simulation/LessonLearningSystem.cs
+++ b/src/HSchool.Simulation/LessonLearningSystem.cs
@@ -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;
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index abc2eb3..3e69a93 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -116,6 +116,9 @@ public sealed class School : IDisposable
internal DaySlot? LastDecisionSlot { get; set; }
+ /// Last slot observed — detects leaving a lesson to finalize absents.
+ internal DaySlot? LastAttendanceSlot { get; set; }
+
internal Dictionary Plans { get; } = new(StringComparer.Ordinal);
internal Queue 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();
@@ -461,6 +466,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);
}
diff --git a/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs b/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs
new file mode 100644
index 0000000..1fc18a6
--- /dev/null
+++ b/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs
@@ -0,0 +1,43 @@
+using HSchool.Content;
+
+namespace HSchool.Content.Tests;
+
+public class AttendanceBehaviorTests
+{
+ private readonly CatalogLoader _loader = new();
+
+ [Fact]
+ public void MissingAttendanceFields_UseVanillaDefaults()
+ {
+ var catalog = _loader.Load(
+ [CatalogLoader.CorePackId],
+ [
+ PackDocuments.Def(
+ CatalogLoader.CorePackId,
+ "behavior",
+ "rules",
+ """{ "defName": "Behavior", "needThreshold": 0.35, "lessonSkillPerHour": 0.05, "commuteSlackMin": 0, "commuteSlackMax": 6, "switchMargin": 0.15 }"""),
+ ]);
+
+ Assert.NotNull(catalog.BehaviorRules);
+ Assert.Equal(40, catalog.BehaviorRules.AttendanceMax);
+ Assert.Equal(5f, catalog.BehaviorRules.LessonLateMinutes);
+ Assert.True(catalog.BehaviorRules.LessonMarkWhenLate);
+ }
+
+ [Fact]
+ public void NegativeLateMinutes_FailTheCatalog()
+ {
+ var error = Assert.Throws(() => _loader.Load(
+ [CatalogLoader.CorePackId],
+ [
+ PackDocuments.Def(
+ CatalogLoader.CorePackId,
+ "behavior",
+ "rules",
+ """{ "defName": "Behavior", "lessonLateMinutes": -1 }"""),
+ ]));
+
+ Assert.Contains("lessonLateMinutes", error.Message, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/HSchool.People.Tests/AttendanceMemoryTests.cs b/tests/HSchool.People.Tests/AttendanceMemoryTests.cs
new file mode 100644
index 0000000..da2e4c3
--- /dev/null
+++ b/tests/HSchool.People.Tests/AttendanceMemoryTests.cs
@@ -0,0 +1,136 @@
+using HSchool.Content;
+
+namespace HSchool.People.Tests;
+
+public class AttendanceMemoryTests
+{
+ [Fact]
+ public void OverCeiling_DropsTheOldest()
+ {
+ var person = Blank("a");
+ var rules = Rules(max: 2);
+ var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
+ Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Present, t0, 1, rules));
+ Assert.True(AttendanceMemory.Record(person, "Literature", AttendanceStatuses.Late, t0.AddMinutes(1), 2, rules));
+ Assert.True(AttendanceMemory.Record(person, "History", AttendanceStatuses.Absent, t0.AddMinutes(2), 3, rules, AbsenceReasons.Truancy));
+
+ Assert.Equal(2, person.Attendance!.Count);
+ Assert.Equal("Literature", person.Attendance[0].Subject);
+ Assert.Equal("History", person.Attendance[1].Subject);
+ Assert.Equal(AbsenceReasons.Truancy, person.Attendance[1].AbsenceReason);
+ }
+
+ [Fact]
+ public void AbsentUpgradesToPresent_SameSlot()
+ {
+ var person = Blank("a");
+ var rules = Rules(max: 40);
+ var t0 = new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc);
+ Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Absent, t0, 1, rules));
+ Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Present, t0.AddMinutes(2), 1, rules));
+ Assert.Single(person.Attendance!);
+ Assert.Equal(AttendanceStatuses.Present, person.Attendance![0].Status);
+ Assert.Null(person.Attendance[0].AbsenceReason);
+ }
+
+ [Fact]
+ public void RosterJson_RoundTripsAttendance_AndOmitsEmpty()
+ {
+ var roster = Fixtures.Generate(Fixtures.Classrooms(4));
+ var pupil = roster.People.First(person => person.IsStudent && !person.IsParent);
+ var time = new DateTime(2012, 4, 3, 11, 20, 0, DateTimeKind.Utc);
+ AttendanceMemory.Record(pupil, "Mathematics", AttendanceStatuses.Late, time, period: 1, Rules(max: 40));
+
+ var json = RosterJson.Serialize(RosterDocument.From(1, roster));
+ Assert.Contains("\"attendance\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"status\": \"late\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"subject\": \"Mathematics\"", json, StringComparison.Ordinal);
+
+ var without = roster.People.First(person => person.Attendance is null || person.Attendance.Count == 0);
+ var withoutSlice = PersonJsonSlice(json, without.Id);
+ Assert.DoesNotContain("\"attendance\"", withoutSlice, StringComparison.Ordinal);
+
+ var loaded = RosterJson.Parse(json).ToRoster();
+ var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal));
+ Assert.NotNull(loadedPupil.Attendance);
+ Assert.Single(loadedPupil.Attendance!);
+ Assert.Equal(AttendanceStatuses.Late, loadedPupil.Attendance[0].Status);
+ Assert.Equal("Mathematics", loadedPupil.Attendance[0].Subject);
+ Assert.Equal(1, loadedPupil.Attendance[0].Period);
+ Assert.Equal(time, loadedPupil.Attendance[0].Time);
+ Assert.Null(loadedPupil.Attendance[0].AbsenceReason);
+ }
+
+ [Fact]
+ public void RosterJson_RoundTripsAbsentReason()
+ {
+ var roster = Fixtures.Generate(Fixtures.Classrooms(4));
+ var pupil = roster.People.First(person => person.IsStudent && !person.IsParent);
+ var time = new DateTime(2012, 4, 3, 11, 20, 0, DateTimeKind.Utc);
+ AttendanceMemory.Record(
+ pupil,
+ "Mathematics",
+ AttendanceStatuses.Absent,
+ time,
+ period: 1,
+ Rules(max: 40),
+ AbsenceReasons.Truancy);
+
+ var loaded = RosterJson.Parse(RosterJson.Serialize(RosterDocument.From(1, roster))).ToRoster();
+ var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal));
+ Assert.Equal(AttendanceStatuses.Absent, loadedPupil.Attendance![0].Status);
+ Assert.Equal(AbsenceReasons.Truancy, loadedPupil.Attendance[0].AbsenceReason);
+ }
+
+ private static string PersonJsonSlice(string json, string personId)
+ {
+ var marker = $"\"id\": \"{personId}\"";
+ var start = json.IndexOf(marker, StringComparison.Ordinal);
+ Assert.True(start >= 0);
+ var end = json.IndexOf("},", start, StringComparison.Ordinal);
+ if (end < 0)
+ {
+ end = json.Length;
+ }
+
+ return json[start..end];
+ }
+
+ private static BehaviorDef Rules(int max) => new()
+ {
+ DefName = "Behavior",
+ AttendanceMax = max,
+ LessonLateMinutes = 5f,
+ LessonMarkWhenLate = true,
+ };
+
+ private static Person Blank(string id)
+ {
+ var cases = new CaseTable
+ {
+ Nom = id,
+ Gen = id,
+ Dat = id,
+ Acc = id,
+ Ins = id,
+ Pre = id,
+ };
+ return new Person
+ {
+ Id = id,
+ FamilyId = "f",
+ Female = false,
+ BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ Name = new PersonName(id, id, id, cases, cases, cases),
+ IsStudent = true,
+ IsStaff = false,
+ IsParent = false,
+ Numbers = new Dictionary(StringComparer.Ordinal),
+ Choices = new Dictionary(StringComparer.Ordinal),
+ Skills = new Dictionary(StringComparer.Ordinal),
+ Traits = [],
+ Needs = new Dictionary(StringComparer.Ordinal),
+ Opinions = new Dictionary(StringComparer.Ordinal),
+ };
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs b/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs
new file mode 100644
index 0000000..c11e4e2
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs
@@ -0,0 +1,256 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation.Tests;
+
+public class AttendanceSimulationTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void InRoomAtStart_IsPresent()
+ {
+ var (school, room, pupilId, _) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ SetPlace(school, pupilId, room);
+ school.Clock.JumpTo(LessonStart);
+ AttendanceSystem.Apply(school);
+
+ var row = RowOf(school, pupilId);
+ Assert.NotNull(row);
+ Assert.Equal(AttendanceStatuses.Present, row!.Status);
+ Assert.Equal("Mathematics", row.Subject);
+ Assert.Equal(1, row.Period);
+ Assert.Null(row.AbsenceReason);
+ }
+ }
+
+ [Fact]
+ public void ArrivesAfterThreshold_IsLate()
+ {
+ var (school, room, pupilId, _) = StaffedMath();
+ using (school)
+ {
+ Assert.Equal(5f, school.Catalog!.BehaviorRules!.LessonLateMinutes);
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ SetPlace(school, pupilId, null);
+ school.Clock.JumpTo(LessonStart);
+ AttendanceSystem.Apply(school);
+ Assert.Null(RowOf(school, pupilId));
+
+ school.Clock.JumpTo(LessonStart.AddMinutes(6));
+ SetPlace(school, pupilId, room);
+ AttendanceSystem.Apply(school);
+
+ var row = RowOf(school, pupilId);
+ Assert.NotNull(row);
+ Assert.Equal(AttendanceStatuses.Late, row!.Status);
+ Assert.Null(row.AbsenceReason);
+ }
+ }
+
+ [Fact]
+ public void OffCampusWholeSlot_IsAbsentTruancy()
+ {
+ var (school, _, pupilId, _) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ SetPlace(school, pupilId, null);
+ school.Clock.JumpTo(LessonStart);
+ AttendanceSystem.Apply(school);
+ Assert.Null(RowOf(school, pupilId));
+
+ school.Clock.JumpTo(LessonStart.AddMinutes(45));
+ AttendanceSystem.Apply(school);
+
+ var row = RowOf(school, pupilId);
+ Assert.NotNull(row);
+ Assert.Equal(AttendanceStatuses.Absent, row!.Status);
+ Assert.Equal(AbsenceReasons.Truancy, row.AbsenceReason);
+ }
+ }
+
+ [Fact]
+ public void OtherNodeWholeSlot_IsAbsent()
+ {
+ var (school, room, pupilId, _) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ var other = room.Equals("classroom-101", StringComparison.Ordinal) ? "classroom-102" : "classroom-101";
+ SetPlace(school, pupilId, other);
+ school.Clock.JumpTo(LessonStart);
+ AttendanceSystem.Apply(school);
+ school.Clock.JumpTo(LessonStart.AddMinutes(45));
+ AttendanceSystem.Apply(school);
+
+ var row = RowOf(school, pupilId);
+ Assert.NotNull(row);
+ Assert.Equal(AttendanceStatuses.Absent, row!.Status);
+ }
+ }
+
+ [Fact]
+ public void SameDayAndSeed_SameAttendance()
+ {
+ var first = CapturePresent();
+ var second = CapturePresent();
+ Assert.Equal(first.Status, second.Status);
+ Assert.Equal(first.Subject, second.Subject);
+ Assert.Equal(first.Period, second.Period);
+ }
+
+ [Fact]
+ public void Late_DoesNotCancelMarkWhenConfigured()
+ {
+ var (school, room, pupilId, teacherId) = StaffedMath();
+ using (school)
+ {
+ Assert.True(school.Catalog!.BehaviorRules!.LessonMarkWhenLate);
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ SetIdle(school, pupilId);
+ SetIdle(school, teacherId);
+ school.Clock.JumpTo(LessonStart.AddMinutes(6));
+ SetPlace(school, pupilId, room);
+ SetPlace(school, teacherId, room);
+ PlaceTextbook(school, pupilId, "Mathematics");
+ LessonLearningSystem.Apply(school, 5);
+ AttendanceSystem.Apply(school);
+
+ Assert.Equal(AttendanceStatuses.Late, RowOf(school, pupilId)!.Status);
+ var marks = school.Roster!.People.First(row => row.Id == pupilId).LessonMarks;
+ Assert.NotNull(marks);
+ Assert.Single(marks!);
+ }
+ }
+
+ private static AttendanceRecord CapturePresent()
+ {
+ var (school, room, pupilId, _) = StaffedMath();
+ using (school)
+ {
+ AdvanceTo(school, LessonStart.AddMinutes(-1));
+ SetPlace(school, pupilId, room);
+ school.Clock.JumpTo(LessonStart);
+ AttendanceSystem.Apply(school);
+ var row = RowOf(school, pupilId);
+ Assert.NotNull(row);
+ return row!;
+ }
+ }
+
+ private static AttendanceRecord? RowOf(School school, string pupilId) =>
+ school.Roster!.People.First(row => row.Id == pupilId).Attendance?.LastOrDefault();
+
+ private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath()
+ {
+ var (catalog, map) = Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
+ var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
+ var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f);
+ Assert.Equal(StaffingError.None, hired.Error);
+ roster = hired.Roster;
+ pool = hired.Pool;
+ var hiredId = roster.People.First(person => person.IsStaff).Id;
+ var schoolClass = roster.Classes.First(row =>
+ row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
+ var school = School.Create(1, "Явка", TuesdayMorning, catalog, map);
+ school.InstallPeople(roster, seed: 1, "Russia", pool);
+ school.SetTimetable(new Timetable(
+ [new LessonPlacement(schoolClass.Id, "Mathematics", hiredId, schoolClass.RoomId, Day: 1, Period: 1)],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ var pupil = schoolClass.PupilIds
+ .Select(id => school.Roster!.People.First(person => person.Id == id))
+ .First(person => !person.Traits.Contains("Lazy"));
+ return (school, schoolClass.RoomId, pupil.Id, hiredId);
+ }
+
+ private static void AdvanceTo(School school, DateTime until)
+ {
+ while (school.Clock.Time < until)
+ {
+ school.Tick(0.2d, 5d);
+ }
+ }
+
+ private static void SetPlace(School school, string personId, string? node, string[]? path = null, float remaining = 0f)
+ {
+ var query = new QueryDescription().WithAll();
+ school.World.Query(
+ in query,
+ (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ if (!identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ presence = node is null
+ ? Presence.OffCampus
+ : new Presence(node, remaining, node, HeadingHome: false, path ?? []);
+ });
+ }
+
+ private static void SetIdle(School school, string personId)
+ {
+ var query = new QueryDescription().WithAll();
+ school.World.Query(
+ in query,
+ (ref PersonIdentity identity, ref PersonActivity activity) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ activity = PersonActivity.Idle;
+ }
+ });
+ }
+
+ private static void PlaceTextbook(School school, string personId, string subject)
+ {
+ var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal));
+ if (person.Items is not IList items || items.IsReadOnly)
+ {
+ throw new InvalidOperationException($"Cannot mutate items for {personId}.");
+ }
+
+ for (var i = items.Count - 1; i >= 0; i--)
+ {
+ if (items[i].Subject is not null)
+ {
+ items.RemoveAt(i);
+ }
+ }
+
+ items.Add(new InventoryItem("Textbook", Color: null, Condition: 1f, ItemLocations.Bag, subject));
+ }
+
+ private static (DefCatalog Catalog, MapLayout Map) Vanilla()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ var documents = new List();
+ foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
+ {
+ if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
+ && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
+ documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
+ }
+
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ return (catalog, map);
+ }
+}