From 59a7023bc47a0a7a9a674888ad293985dfadb665 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 14:18:41 +0300 Subject: [PATCH] Wire grade trail notices, dress mark factor, and truancy summons weights. Lesson multipliers stay explicit in tests; poor marks and unexcused absences toast and may auto-summon, while illness stays excused. --- docs/phases/13-grades/76-grades-coupling.md | 20 +- src/HSchool.Content/EventDefValidator.cs | 1 + src/HSchool.Content/EventDefs.cs | 3 + src/HSchool.Content/PeopleDefValidator.cs | 30 +++ src/HSchool.Content/PeopleDefs.cs | 28 ++ src/HSchool.People/GradeTrail.cs | 90 +++++++ src/HSchool.People/OffenseMemory.cs | 4 + src/HSchool.People/Seed.cs | 1 + .../mods/core/defs/behavior/rules.jsonc | 9 +- .../mods/core/defs/events/notices.jsonc | 8 + .../mods/core/localizations/en.jsonc | 4 + .../mods/core/localizations/ru.jsonc | 4 + src/HSchool.Simulation/AttendanceSystem.cs | 1 + src/HSchool.Simulation/GradeTrailSystem.cs | 78 ++++++ .../LessonLearningSystem.cs | 9 + src/HSchool.Simulation/PersonLog.cs | 14 +- src/HSchool.Simulation/School.cs | 11 + .../LessonMarkCouplingTests.cs | 68 +++++ .../GradebookApiTests.cs | 12 + tests/HSchool.Content.Tests/EventDefTests.cs | 6 + .../GradeTrailBehaviorTests.cs | 44 ++++ tests/HSchool.People.Tests/GradeTrailTests.cs | 88 +++++++ .../GradeTrailCouplingTests.cs | 242 ++++++++++++++++++ 23 files changed, 762 insertions(+), 13 deletions(-) create mode 100644 src/HSchool.People/GradeTrail.cs create mode 100644 src/HSchool.Simulation/GradeTrailSystem.cs create mode 100644 tests/HSchool.Ai.Tests/LessonMarkCouplingTests.cs create mode 100644 tests/HSchool.Content.Tests/GradeTrailBehaviorTests.cs create mode 100644 tests/HSchool.People.Tests/GradeTrailTests.cs create mode 100644 tests/HSchool.Simulation.Tests/GradeTrailCouplingTests.cs diff --git a/docs/phases/13-grades/76-grades-coupling.md b/docs/phases/13-grades/76-grades-coupling.md index bdfb390..005eb30 100644 --- a/docs/phases/13-grades/76-grades-coupling.md +++ b/docs/phases/13-grades/76-grades-coupling.md @@ -12,20 +12,20 @@ ## Задачи -- [ ] Явно учесть в качестве/оценке уже существующие множители (учитель, тепло, учебник, голод, +- [x] Явно учесть в качестве/оценке уже существующие множители (учитель, тепло, учебник, голод, шёпот) — тесты на каждый вклад -- [ ] Уместность формы: лёгкий модификатор оценки или только лог — число в `BehaviorDef` -- [ ] Плохой средний след / прогулы → `EventDef` info; веса к авто-вызову (12), если срез 12 в мире -- [ ] Классный (если назначен) пишет заметку в лог / учитывается в пороге — без кнопки игрока -- [ ] Контракт для 14: причина явки «болезнь» не зовёт вызов как прогул +- [x] Уместность формы: лёгкий модификатор оценки или только лог — число в `BehaviorDef` +- [x] Плохой средний след / прогулы → `EventDef` info; веса к авто-вызову (12), если срез 12 в мире +- [x] Классный (если назначен) пишет заметку в лог / учитывается в пороге — без кнопки игрока +- [x] Контракт для 14: причина явки «болезнь» не зовёт вызов как прогул ## Тесты, без которых фаза не закрыта -- [ ] Шёпот на уроке ухудшает оценку относительно контроля при тех же прочих -- [ ] Нет учебника ухудшает относительно контроля -- [ ] Порог плохого следа эмитит notice -- [ ] Прогул с причиной «болезнь» (заглушка/флаг) не добавляет проступок вызова -- [ ] Нет API «вызови за двойку» от игрока +- [x] Шёпот на уроке ухудшает оценку относительно контроля при тех же прочих +- [x] Нет учебника ухудшает относительно контроля +- [x] Порог плохого следа эмитит notice +- [x] Прогул с причиной «болезнь» (заглушка/флаг) не добавляет проступок вызова +- [x] Нет API «вызови за двойку» от игрока ## Критерий готовности diff --git a/src/HSchool.Content/EventDefValidator.cs b/src/HSchool.Content/EventDefValidator.cs index de59416..c37b01d 100644 --- a/src/HSchool.Content/EventDefValidator.cs +++ b/src/HSchool.Content/EventDefValidator.cs @@ -17,6 +17,7 @@ internal static class EventDefValidator EventTriggers.DirectorSummon, EventTriggers.ParentMeeting, EventTriggers.DiseaseOutbreak, + EventTriggers.PoorGradeTrail, }; private static readonly HashSet Actions = new(StringComparer.Ordinal) diff --git a/src/HSchool.Content/EventDefs.cs b/src/HSchool.Content/EventDefs.cs index bf97aa4..d73a0ff 100644 --- a/src/HSchool.Content/EventDefs.cs +++ b/src/HSchool.Content/EventDefs.cs @@ -15,6 +15,9 @@ public static class EventTriggers public const string DirectorSummon = "directorSummon"; public const string ParentMeeting = "parentMeeting"; public const string DiseaseOutbreak = "diseaseOutbreak"; + + /// Bad recent marks and/or truancy trail (slice 13 phase 76). + public const string PoorGradeTrail = "poorGradeTrail"; } public static class EventActions diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index d837a77..5363bdd 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -752,6 +752,36 @@ internal static class PeopleDefValidator $"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative."); } + if (behavior.LessonDressMarkFactor is < 0f or > 1f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonDressMarkFactor must be 0–1."); + } + + if (behavior.PoorMarkTrailMaxAverage is < 0f or > 5f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' poorMarkTrailMaxAverage must be 0–5."); + } + + if (behavior.PoorMarkTrailMinMarks < 0) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' poorMarkTrailMinMarks cannot be negative."); + } + + if (behavior.TruancyTrailMinCount < 0) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' truancyTrailMinCount cannot be negative."); + } + + if (behavior.GradeTrailSummonChance is < 0f or > 1f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' gradeTrailSummonChance must be 0–1."); + } + if (behavior.DiseaseVectorScale < 0f) { throw new ContentLoadException( diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index bd7c8c6..ebdb702 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -619,6 +619,7 @@ public sealed class BehaviorDef : Def "quarrel", "fight", "reprimand", + "truancy", ]; /// @@ -655,6 +656,33 @@ public sealed class BehaviorDef : Def /// public bool LessonMarkWhenLate { get; init; } = true; + /// + /// Multiplier on lesson quality when worn dress fails Appropriateness for this lesson. + /// 1 = log only (no mark hit). Vanilla is a light penalty. + /// + public float LessonDressMarkFactor { get; init; } = 0.95f; + + /// + /// Mean of recent marks at or below this (and at least ) + /// raises a poor-grade-trail notice. Zero disables the mark half of the trail. + /// + public float PoorMarkTrailMaxAverage { get; init; } = 2.5f; + + /// How many recent marks are required before the average trail can fire. Zero disables. + public int PoorMarkTrailMinMarks { get; init; } = 3; + + /// + /// Recent unexcused absences (truancy) at or above this raise the trail notice. + /// Zero disables the truancy half. + /// + public int TruancyTrailMinCount { get; init; } = 2; + + /// + /// Chance to auto-enqueue a director summons when a poor trail first fires today. + /// Slice 12 must be able to start (principal + office); 0 skips the roll. + /// + public float GradeTrailSummonChance { get; init; } = 0.35f; + /// /// Scales DiseaseDef weather/base onset chances. 0 disables natural onset; missing keeps 1. /// diff --git a/src/HSchool.People/GradeTrail.cs b/src/HSchool.People/GradeTrail.cs new file mode 100644 index 0000000..493befe --- /dev/null +++ b/src/HSchool.People/GradeTrail.cs @@ -0,0 +1,90 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// +/// Pure trail checks over recent marks and attendance. Simulation decides when to notice or summon. +/// +public static class GradeTrail +{ + public static float? AverageMark(Person person) + { + ArgumentNullException.ThrowIfNull(person); + var list = person.LessonMarks; + if (list is null || list.Count == 0) + { + return null; + } + + var total = 0; + foreach (var row in list) + { + total += row.Value; + } + + return total / (float)list.Count; + } + + public static int TruancyCount(Person person) + { + ArgumentNullException.ThrowIfNull(person); + var list = person.Attendance; + if (list is null || list.Count == 0) + { + return 0; + } + + var count = 0; + foreach (var row in list) + { + if (row.Status == AttendanceStatuses.Absent + && (row.AbsenceReason is null + || row.AbsenceReason.Equals(AbsenceReasons.Truancy, StringComparison.Ordinal))) + { + count++; + } + } + + return count; + } + + public static bool HasPoorMarks(Person person, BehaviorDef rules) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(rules); + if (rules.PoorMarkTrailMinMarks <= 0 || rules.PoorMarkTrailMaxAverage <= 0f) + { + return false; + } + + var list = person.LessonMarks; + if (list is null || list.Count < rules.PoorMarkTrailMinMarks) + { + return false; + } + + return AverageMark(person) is { } average && average <= rules.PoorMarkTrailMaxAverage; + } + + public static bool HasTruancyTrail(Person person, BehaviorDef rules) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(rules); + if (rules.TruancyTrailMinCount <= 0) + { + return false; + } + + return TruancyCount(person) >= rules.TruancyTrailMinCount; + } + + public static bool IsPoor(Person person, BehaviorDef rules) => + HasPoorMarks(person, rules) || HasTruancyTrail(person, rules); + + /// + /// Illness absences are excused for summons / offense memory. Truancy and unknown reasons are not. + /// + public static bool CountsAsSummonOffense(string? absenceReason) => + absenceReason is null + || absenceReason.Equals(AbsenceReasons.Truancy, StringComparison.Ordinal); +} diff --git a/src/HSchool.People/OffenseMemory.cs b/src/HSchool.People/OffenseMemory.cs index 9c03a46..26e6ed0 100644 --- a/src/HSchool.People/OffenseMemory.cs +++ b/src/HSchool.People/OffenseMemory.cs @@ -12,11 +12,15 @@ public static class OffenseKinds public const string Reprimand = "reprimand"; + /// Unexcused lesson absence (slice 13). Illness does not use this kind. + public const string Truancy = "truancy"; + public static string LocaleKey(string kind) => kind switch { Quarrel => "OffenseQuarrel", Fight => "OffenseFight", Reprimand => "OffenseReprimand", + Truancy => "OffenseTruancy", _ => "Offense" + char.ToUpperInvariant(kind[0]) + kind[1..], }; } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index f595e71..1348a85 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -27,6 +27,7 @@ public static class Seed public const int MeetingAttendSalt = 19; public const int DiseaseSalt = 20; public const int ContagionSalt = 21; + public const int GradeTrailSalt = 22; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index f848727..2651dd7 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -98,7 +98,7 @@ "defendOpinionMin": 40, // Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score. "offenseMemoryMax": 5, - "offenseMemoryKinds": ["quarrel", "fight", "reprimand"], + "offenseMemoryKinds": ["quarrel", "fight", "reprimand", "truancy"], // Auto director summons (slice 12 phase 70). Fight-break always; reprimand rarer. "directorSummonFightBreakChance": 1, "directorSummonReprimandChance": 0.35, @@ -121,6 +121,13 @@ "lessonLateMinutes": 5, // Late still gets a mark when standing (stage A). False would skip the mark for late arrivals. "lessonMarkWhenLate": true, + // Dress fails Appropriateness on this lesson: light mark penalty (1 = log only). + "lessonDressMarkFactor": 0.95, + // Poor recent average / truancy trail → info toast + optional auto summons (slice 13 phase 76). + "poorMarkTrailMaxAverage": 2.5, + "poorMarkTrailMinMarks": 3, + "truancyTrailMinCount": 2, + "gradeTrailSummonChance": 0.35, // Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset. "diseaseVectorScale": 1, // Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79). diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc index c81e154..4d350b7 100644 --- a/src/HSchool.Server/mods/core/defs/events/notices.jsonc +++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc @@ -47,4 +47,12 @@ "trigger": "diseaseOutbreak", "action": "none", }, + { + "defName": "PoorGradeTrail", + "severity": "info", + "pause": false, + "ttlMs": 8000, + "trigger": "poorGradeTrail", + "action": "none", + }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 39377a9..d8daea2 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -211,6 +211,7 @@ "OffenseQuarrel": "quarrel", "OffenseFight": "fight", "OffenseReprimand": "reprimand", + "OffenseTruancy": "truancy", "AttendancePresent": "present", "AttendanceLate": "late", "AttendanceAbsent": "absent", @@ -231,6 +232,9 @@ "LessonNoTeacher": "lesson without a teacher: {0}", "LessonCold": "too cold in class: {0}", "LessonNoTextbook": "no textbook: {0}", + "LessonDress": "dress code issue: {0}", + "ClassTeacherGradeNote": "the class teacher noted a weak trail", + "PoorGradeTrail": "A pupil has a weak grade trail", "DayStarted": "The day has started", "LessonStarted": "A lesson has started", "GenerationFailed": "Portrait generation failed", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 343578b..c6843e0 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -211,6 +211,7 @@ "OffenseQuarrel": "ссора", "OffenseFight": "драка", "OffenseReprimand": "выговор", + "OffenseTruancy": "прогул", "AttendancePresent": "был", "AttendanceLate": "опоздал", "AttendanceAbsent": "отсутствовал", @@ -231,6 +232,9 @@ "LessonNoTeacher": "урок без учителя: {0}", "LessonCold": "замёрз на уроке: {0}", "LessonNoTextbook": "нет учебника: {0}", + "LessonDress": "форма не по правилам: {0}", + "ClassTeacherGradeNote": "классный отметил слабый след", + "PoorGradeTrail": "Слабый учебный след у ученика", "DayStarted": "Начало дня", "LessonStarted": "Начало урока", "GenerationFailed": "Не удалось нарисовать портрет", diff --git a/src/HSchool.Simulation/AttendanceSystem.cs b/src/HSchool.Simulation/AttendanceSystem.cs index 8505f05..f5427cc 100644 --- a/src/HSchool.Simulation/AttendanceSystem.cs +++ b/src/HSchool.Simulation/AttendanceSystem.cs @@ -136,6 +136,7 @@ internal static class AttendanceSystem reason)) { school.RosterTalkDirty = true; + GradeTrailSystem.AfterAbsent(school, person, reason); } } } diff --git a/src/HSchool.Simulation/GradeTrailSystem.cs b/src/HSchool.Simulation/GradeTrailSystem.cs new file mode 100644 index 0000000..0813ec0 --- /dev/null +++ b/src/HSchool.Simulation/GradeTrailSystem.cs @@ -0,0 +1,78 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// After marks or absences: info toast, class-teacher log, and optional auto summons weight. +/// Illness absences do not become summons offenses. +/// +internal static class GradeTrailSystem +{ + public static void AfterMark(School school, Person person) + { + if (school.Catalog?.BehaviorRules is not { } rules) + { + return; + } + + TryReact(school, person, rules); + } + + public static void AfterAbsent(School school, Person person, string? absenceReason) + { + if (school.Catalog?.BehaviorRules is not { } rules) + { + return; + } + + if (GradeTrail.CountsAsSummonOffense(absenceReason) + && OffenseMemory.Record(person, OffenseKinds.Truancy, school.Clock.Time, otherPersonId: null, rules)) + { + school.RosterTalkDirty = true; + } + + TryReact(school, person, rules); + } + + private static void TryReact(School school, Person person, BehaviorDef rules) + { + if (!GradeTrail.IsPoor(person, rules)) + { + return; + } + + if (!school.TryClaimGradeTrailNotice(person.Id, school.Clock.Time.Date)) + { + return; + } + + school.RaiseWorldEvent(new WorldEvent(EventTriggers.PoorGradeTrail, person.Id)); + + var schoolClass = ClassOf(school, person); + if (schoolClass?.ClassTeacherId is { } teacherId) + { + school.AppendDayLog(new PersonLogEvent( + person.Id, + school.Clock.Time, + PersonLogTypes.ClassTeacherGradeNote, + teacherId)); + } + + if (rules.GradeTrailSummonChance > 0f) + { + DirectorSummonSystem.TryEnqueue(school, person.Id, rules.GradeTrailSummonChance); + } + } + + private static SchoolClass? ClassOf(School school, Person person) + { + if (person.ClassId is null || school.Roster 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 bcde170..1023d83 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -112,6 +112,13 @@ internal static class LessonLearningSystem textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules); textbookFactor *= DiseaseEffects.LessonLearningFactor(person, catalog, school.Clock.Time); + var mode = ApparelDresser.ModeForLesson(lesson.Subject); + if (ApparelDresser.CurrentIssues(school, person, mode) != ApparelIssue.None) + { + school.TryLogLessonOnce(personId, PersonLogTypes.LessonDress, lesson.Subject); + textbookFactor *= rules.LessonDressMarkFactor; + } + IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found : new Dictionary(StringComparer.Ordinal); @@ -165,6 +172,7 @@ internal static class LessonLearningSystem if (LessonMarkMemory.Record(person, lesson.Subject, mark, school.Clock.Time, lesson.Period, rules)) { school.RosterTalkDirty = true; + GradeTrailSystem.AfterMark(school, person); } } @@ -194,6 +202,7 @@ internal static class LessonLearningSystem if (LessonMarkMemory.Record(person, lesson.Subject, mark, school.Clock.Time, lesson.Period, rules)) { school.RosterTalkDirty = true; + GradeTrailSystem.AfterMark(school, person); } } diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 788e9e5..f6ce8ae 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -25,6 +25,8 @@ public static class PersonLogTypes public const string LessonNoTeacher = "lesson-no-teacher"; public const string LessonCold = "lesson-cold"; public const string LessonNoTextbook = "lesson-no-textbook"; + public const string LessonDress = "lesson-dress"; + public const string ClassTeacherGradeNote = "class-teacher-grade-note"; } /// @@ -102,7 +104,8 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, if (Type.Equals(PersonLogTypes.LessonNoTeacher, StringComparison.Ordinal) || Type.Equals(PersonLogTypes.LessonCold, StringComparison.Ordinal) - || Type.Equals(PersonLogTypes.LessonNoTextbook, StringComparison.Ordinal)) + || Type.Equals(PersonLogTypes.LessonNoTextbook, StringComparison.Ordinal) + || Type.Equals(PersonLogTypes.LessonDress, StringComparison.Ordinal)) { var name = catalog.Subjects.TryGetValue(ThingDef, out var subject) ? catalog.Label(locale, subject) @@ -111,10 +114,17 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, ? "LessonCold" : Type.Equals(PersonLogTypes.LessonNoTextbook, StringComparison.Ordinal) ? "LessonNoTextbook" - : "LessonNoTeacher"; + : Type.Equals(PersonLogTypes.LessonDress, StringComparison.Ordinal) + ? "LessonDress" + : "LessonNoTeacher"; return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } + if (Type.Equals(PersonLogTypes.ClassTeacherGradeNote, StringComparison.Ordinal)) + { + return catalog.Text(locale, "ClassTeacherGradeNote"); + } + if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal) || Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal)) { diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 6d82cdd..939f90c 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -19,6 +19,7 @@ public sealed class School : IDisposable private readonly List _dayLog = []; private readonly HashSet _lessonLogOnce = new(StringComparer.Ordinal); private readonly HashSet _lessonMarkOnce = new(StringComparer.Ordinal); + private readonly HashSet _gradeTrailOnce = new(StringComparer.Ordinal); private readonly List _worldEvents = []; internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map) @@ -176,6 +177,7 @@ public sealed class School : IDisposable LoggedActivity.Clear(); _lessonLogOnce.Clear(); _lessonMarkOnce.Clear(); + _gradeTrailOnce.Clear(); LastAttendanceSlot = null; } @@ -205,6 +207,15 @@ public sealed class School : IDisposable return _lessonMarkOnce.Add(key); } + /// + /// One poor-grade-trail notice per person per calendar day (toast + class-teacher note + summon roll). + /// + internal bool TryClaimGradeTrailNotice(string personId, DateTime day) + { + var key = string.Concat(personId, "\0", day.ToString("yyyy-MM-dd")); + return _gradeTrailOnce.Add(key); + } + public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/tests/HSchool.Ai.Tests/LessonMarkCouplingTests.cs b/tests/HSchool.Ai.Tests/LessonMarkCouplingTests.cs new file mode 100644 index 0000000..6054a21 --- /dev/null +++ b/tests/HSchool.Ai.Tests/LessonMarkCouplingTests.cs @@ -0,0 +1,68 @@ +using HSchool.Content; + +namespace HSchool.Ai.Tests; + +public class LessonMarkCouplingTests +{ + private static readonly BehaviorDef Rules = new() + { + DefName = "Behavior", + LessonMarkThresholds = BehaviorDef.DefaultLessonMarkThresholds, + LessonMarkMax = 40, + LessonNoTextbookFactor = 0.5f, + LessonWhisperSkillFactor = 0.4f, + }; + + [Fact] + public void WhisperFactor_WorsensMarkVsQuiet() + { + var quiet = LessonLearning.Mark( + LessonLearning.Quality(1f, 0, 100f, 1f, textbookFactor: 1f), + Rules); + var whisper = LessonLearning.Mark( + LessonLearning.Quality(1f, 0, 100f, 1f, textbookFactor: Rules.LessonWhisperSkillFactor), + Rules); + + Assert.True(quiet > whisper); + } + + [Fact] + public void NoTextbook_WorsensMarkVsWithTextbook() + { + var withBook = LessonLearning.Mark( + LessonLearning.Quality(1f, 0, 100f, 1f, textbookFactor: 1f), + Rules); + var without = LessonLearning.Mark( + LessonLearning.Quality(1f, 0, 100f, 1f, textbookFactor: Rules.LessonNoTextbookFactor), + Rules); + + Assert.True(withBook > without); + } + + [Fact] + public void Hunger_WorsensMarkVsFull() + { + var full = LessonLearning.Mark(LessonLearning.Quality(1f, 0, 100f, 1f, 1f), Rules); + var hungry = LessonLearning.Mark(LessonLearning.Quality(0.2f, 0, 100f, 1f, 1f), Rules); + + Assert.True(full > hungry); + } + + [Fact] + public void Cold_WorsensMarkVsWarm() + { + var warm = LessonLearning.Mark(LessonLearning.Quality(1f, 0, 100f, warmth: 1f, textbookFactor: 1f), Rules); + var cold = LessonLearning.Mark(LessonLearning.Quality(1f, 0, 100f, warmth: 0.2f, textbookFactor: 1f), Rules); + + Assert.True(warm > cold); + } + + [Fact] + public void WeakTeacher_WorsensMarkVsStrong() + { + var strong = LessonLearning.Mark(LessonLearning.Quality(1f, 0, teacherSkill: 100f, 1f, 1f), Rules); + var weak = LessonLearning.Mark(LessonLearning.Quality(1f, 0, teacherSkill: 10f, 1f, 1f), Rules); + + Assert.True(strong > weak); + } +} diff --git a/tests/HSchool.AppHost.Tests/GradebookApiTests.cs b/tests/HSchool.AppHost.Tests/GradebookApiTests.cs index 6c2b688..43e29f2 100644 --- a/tests/HSchool.AppHost.Tests/GradebookApiTests.cs +++ b/tests/HSchool.AppHost.Tests/GradebookApiTests.cs @@ -45,6 +45,18 @@ public class GradebookApiTests(AppHostFixture fixture) new { subject = "Mathematics", status = "present", period = 1 }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, attendancePut.StatusCode); + + using var summonPost = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/people/{personId}/summon", + new { reason = "mark", value = 2 }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, summonPost.StatusCode); + + using var summonForMark = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/people/{personId}/summon-for-mark", + new { value = 2 }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, summonForMark.StatusCode); } [Fact] diff --git a/tests/HSchool.Content.Tests/EventDefTests.cs b/tests/HSchool.Content.Tests/EventDefTests.cs index a28ed73..a64772c 100644 --- a/tests/HSchool.Content.Tests/EventDefTests.cs +++ b/tests/HSchool.Content.Tests/EventDefTests.cs @@ -42,6 +42,12 @@ public class EventDefTests Assert.Equal(EventActions.None, catalog.Events["DirectorSummoned"].Action); Assert.Equal("Ученика вызвали к директору", catalog.Label("ru", catalog.Events["DirectorSummoned"])); Assert.Equal("A pupil was summoned to the principal", catalog.Label("en", catalog.Events["DirectorSummoned"])); + + Assert.True(catalog.Events.ContainsKey("PoorGradeTrail")); + Assert.Equal(EventSeverities.Info, catalog.Events["PoorGradeTrail"].Severity); + Assert.Equal(EventTriggers.PoorGradeTrail, catalog.Events["PoorGradeTrail"].Trigger); + Assert.Equal("Слабый учебный след у ученика", catalog.Label("ru", catalog.Events["PoorGradeTrail"])); + Assert.Equal("A pupil has a weak grade trail", catalog.Label("en", catalog.Events["PoorGradeTrail"])); } [Fact] diff --git a/tests/HSchool.Content.Tests/GradeTrailBehaviorTests.cs b/tests/HSchool.Content.Tests/GradeTrailBehaviorTests.cs new file mode 100644 index 0000000..2af5ec5 --- /dev/null +++ b/tests/HSchool.Content.Tests/GradeTrailBehaviorTests.cs @@ -0,0 +1,44 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class GradeTrailBehaviorTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaCore_LoadsGradeTrailFields() + { + var catalog = LoadVanilla(); + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(0.95f, catalog.BehaviorRules.LessonDressMarkFactor); + Assert.Equal(2.5f, catalog.BehaviorRules.PoorMarkTrailMaxAverage); + Assert.Equal(3, catalog.BehaviorRules.PoorMarkTrailMinMarks); + Assert.Equal(2, catalog.BehaviorRules.TruancyTrailMinCount); + Assert.Equal(0.35f, catalog.BehaviorRules.GradeTrailSummonChance); + Assert.Contains("truancy", catalog.BehaviorRules.OffenseMemoryKinds); + } + + [Fact] + public void DressFactorAboveOne_FailsTheCatalog() + { + var documents = PackDocuments.FromDirectory( + CatalogLoader.CorePackId, + Path.Combine(AppContext.BaseDirectory, "vanilla")) + .Append(PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "bad-dress", + """{ "defName": "Behavior", "lessonDressMarkFactor": 1.5 }""")) + .ToList(); + + var ex = Assert.Throws(() => _loader.Load([CatalogLoader.CorePackId], documents)); + Assert.Contains("lessonDressMarkFactor", ex.Message, StringComparison.Ordinal); + } + + private DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } +} diff --git a/tests/HSchool.People.Tests/GradeTrailTests.cs b/tests/HSchool.People.Tests/GradeTrailTests.cs new file mode 100644 index 0000000..28e8f31 --- /dev/null +++ b/tests/HSchool.People.Tests/GradeTrailTests.cs @@ -0,0 +1,88 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.People.Tests; + +public class GradeTrailTests +{ + private static readonly BehaviorDef Rules = new() + { + DefName = "Behavior", + LessonMarkMax = 40, + AttendanceMax = 40, + PoorMarkTrailMaxAverage = 2.5f, + PoorMarkTrailMinMarks = 3, + TruancyTrailMinCount = 2, + OffenseMemoryMax = 5, + OffenseMemoryKinds = BehaviorDef.DefaultOffenseMemoryKinds, + }; + + [Fact] + public void ThreeTwos_IsPoorMarks() + { + var person = Blank("p1"); + var t0 = new DateTime(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc); + Assert.True(LessonMarkMemory.Record(person, "Mathematics", 2, t0, 1, Rules)); + Assert.True(LessonMarkMemory.Record(person, "Mathematics", 2, t0.AddHours(1), 2, Rules)); + Assert.True(LessonMarkMemory.Record(person, "Literature", 2, t0.AddHours(2), 3, Rules)); + + Assert.True(GradeTrail.HasPoorMarks(person, Rules)); + Assert.True(GradeTrail.IsPoor(person, Rules)); + Assert.Equal(2f, GradeTrail.AverageMark(person)); + } + + [Fact] + public void TwoTruancies_IsTruancyTrail_IllnessDoesNotCount() + { + var person = Blank("p1"); + var t0 = new DateTime(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc); + Assert.True(AttendanceMemory.Record( + person, "Mathematics", AttendanceStatuses.Absent, t0, 1, Rules, AbsenceReasons.Truancy)); + Assert.True(AttendanceMemory.Record( + person, "Literature", AttendanceStatuses.Absent, t0.AddHours(1), 2, Rules, AbsenceReasons.Illness)); + Assert.False(GradeTrail.HasTruancyTrail(person, Rules)); + + Assert.True(AttendanceMemory.Record( + person, "History", AttendanceStatuses.Absent, t0.AddHours(2), 3, Rules, AbsenceReasons.Truancy)); + Assert.True(GradeTrail.HasTruancyTrail(person, Rules)); + Assert.Equal(2, GradeTrail.TruancyCount(person)); + } + + [Fact] + public void Illness_DoesNotCountAsSummonOffense() + { + Assert.False(GradeTrail.CountsAsSummonOffense(AbsenceReasons.Illness)); + Assert.True(GradeTrail.CountsAsSummonOffense(AbsenceReasons.Truancy)); + Assert.True(GradeTrail.CountsAsSummonOffense(null)); + } + + 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/GradeTrailCouplingTests.cs b/tests/HSchool.Simulation.Tests/GradeTrailCouplingTests.cs new file mode 100644 index 0000000..6a616f5 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/GradeTrailCouplingTests.cs @@ -0,0 +1,242 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class GradeTrailCouplingTests +{ + 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 WhisperOnLesson_MarkWorseThanQuiet() + { + var quiet = RunMathMark(whisper: false); + var whispered = RunMathMark(whisper: true); + Assert.True(quiet > whispered); + } + + [Fact] + public void NoTextbook_MarkWorseThanWithTextbook() + { + var withBook = RunMathMark(whisper: false, textbook: true); + var without = RunMathMark(whisper: false, textbook: false); + Assert.True(withBook > without); + } + + [Fact] + public void PoorMarkTrail_EmitsNotice() + { + var (school, _, pupilId, _) = StaffedMath(); + using (school) + { + var rules = school.Catalog!.BehaviorRules!; + var person = school.Roster!.People.First(row => row.Id == pupilId); + var t0 = LessonStart; + Assert.True(LessonMarkMemory.Record(person, "Mathematics", 2, t0, 1, rules)); + Assert.True(LessonMarkMemory.Record(person, "Literature", 2, t0.AddHours(1), 2, rules)); + Assert.True(LessonMarkMemory.Record(person, "History", 2, t0.AddHours(2), 3, rules)); + + school.DrainWorldEvents(); + GradeTrailSystem.AfterMark(school, person); + + Assert.Contains( + school.DrainWorldEvents(), + row => row.Trigger.Equals(EventTriggers.PoorGradeTrail, StringComparison.Ordinal) + && row.PersonKey.Equals(pupilId, StringComparison.Ordinal)); + } + } + + [Fact] + public void IllnessAbsent_DoesNotAddTruancyOffense() + { + var (school, _, pupilId, _) = StaffedMath(); + using (school) + { + var person = school.Roster!.People.First(row => row.Id == pupilId); + person.Offenses = null; + GradeTrailSystem.AfterAbsent(school, person, AbsenceReasons.Illness); + Assert.True(person.Offenses is null || person.Offenses.Count == 0); + + GradeTrailSystem.AfterAbsent(school, person, AbsenceReasons.Truancy); + Assert.NotNull(person.Offenses); + Assert.Contains(person.Offenses!, row => row.Kind == OffenseKinds.Truancy); + } + } + + private static int RunMathMark(bool whisper, bool textbook = true) + { + var (school, room, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + school.ResetDayLog(); + var pupil = school.Roster!.People.First(row => row.Id == pupilId); + pupil.LessonMarks = null; + + SetPlace(school, pupilId, room); + SetPlace(school, teacherId, room); + SetSkill(school, teacherId, "Mathematics", 100f); + SetNeed(school, pupilId, "Hunger", 1f); + SetNeed(school, pupilId, "Warmth", 1f); + if (textbook) + { + PlaceTextbook(school, pupilId, "Mathematics"); + } + else + { + ClearTextbooks(school, pupilId); + } + + if (whisper) + { + var partner = school.Roster!.Classes + .SelectMany(row => row.PupilIds) + .First(id => id != pupilId); + SetPlace(school, partner, room); + PlaceTextbook(school, partner, "Mathematics"); + Assert.True(school.TryStartAction(pupilId, TalkActions.Whisper)); + } + + LessonLearningSystem.Apply(school, 45); + var mark = MarkOf(school, pupilId); + Assert.NotNull(mark); + return mark!.Value; + } + } + + private static LessonMarkRecord? MarkOf(School school, string pupilId) => + school.Roster!.People.First(row => row.Id == pupilId).LessonMarks?.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 PlaceTextbook(School school, string personId, string subject, string location = ItemLocations.Bag) + { + 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}."); + } + + ClearTextbooks(school, personId); + items.Add(new InventoryItem("Textbook", Color: null, Condition: 1f, location, subject)); + } + + private static void ClearTextbooks(School school, string personId) + { + 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); + } + } + } + + 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) + { + TalkCircleSystem.Interrupt(school, personId); + school.TalkCircleByPerson.Remove(personId); + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + presence = new Presence(node, remaining, node, HeadingHome: false, path ?? []); + activity = PersonActivity.Idle; + intent = Intent.None; + } + }); + } + + private static void SetSkill(School school, string personId, string skill, float value) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonSkills skills) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + skills.Values[skill] = value; + } + }); + } + + private static void SetNeed(School school, string personId, string need, float value) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonNeeds needs) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + needs.Values[need] = value; + } + }); + } + + 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); + } +}