From b359bebb41c22b60cc5d6d53b9f7daa55cb10d37 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 11:45:14 +0300 Subject: [PATCH 1/2] Add lesson marks 2-5 from lesson quality once per slot. Co-authored-by: Cursor --- docs/design/13-grades/grades.md | 4 +- docs/phases/13-grades/73-lesson-marks.md | 20 +- src/HSchool.Ai/LessonLearning.cs | 44 ++++- src/HSchool.Content/PeopleDefValidator.cs | 39 ++++ src/HSchool.Content/PeopleDefs.cs | 24 +++ src/HSchool.People/LessonMarks.cs | 59 ++++++ src/HSchool.People/Roster.cs | 6 + .../mods/core/defs/behavior/rules.jsonc | 6 + .../LessonLearningSystem.cs | 72 ++++++- src/HSchool.Simulation/School.cs | 11 ++ tests/HSchool.Ai.Tests/LessonMarkTests.cs | 65 +++++++ .../LessonMarkApiTests.cs | 41 ++++ .../LessonMarkBehaviorTests.cs | 43 +++++ .../LessonMarkMemoryTests.cs | 100 ++++++++++ .../LessonMarkProtocolTests.cs | 14 ++ .../LessonMarkSimulationTests.cs | 175 ++++++++++++++++++ 16 files changed, 702 insertions(+), 21 deletions(-) create mode 100644 src/HSchool.People/LessonMarks.cs create mode 100644 tests/HSchool.Ai.Tests/LessonMarkTests.cs create mode 100644 tests/HSchool.AppHost.Tests/LessonMarkApiTests.cs create mode 100644 tests/HSchool.Content.Tests/LessonMarkBehaviorTests.cs create mode 100644 tests/HSchool.People.Tests/LessonMarkMemoryTests.cs create mode 100644 tests/HSchool.Protocol.Tests/LessonMarkProtocolTests.cs create mode 100644 tests/HSchool.Simulation.Tests/LessonMarkSimulationTests.cs diff --git a/docs/design/13-grades/grades.md b/docs/design/13-grades/grades.md index c368a26..9f3e9ef 100644 --- a/docs/design/13-grades/grades.md +++ b/docs/design/13-grades/grades.md @@ -55,8 +55,8 @@ за слот урока, если ученик хотя бы частично присутствовал и рост мог бы идти). Шкала классическая: 2, 3, 4, 5. Пороги от нормализованного «качества урока» — данные, не UI. -Нет учителя в кабинете / множитель 0 → не пятёрка «за воздух»: либо пропуск оценки, либо 2 по -правилу в данных (явно в фазе). +Нет учителя в кабинете → ваниль пишет **2** (`BehaviorDef.lessonMarkWhenNoTeacher`; `null` — +пропуск оценки). Множитель 0 (например учебник) → качество 0 → оценка 2 по порогам. Сейв — на ученике, разреженно (последние N по предмету / общий потолок). diff --git a/docs/phases/13-grades/73-lesson-marks.md b/docs/phases/13-grades/73-lesson-marks.md index 5b21114..df2105f 100644 --- a/docs/phases/13-grades/73-lesson-marks.md +++ b/docs/phases/13-grades/73-lesson-marks.md @@ -13,20 +13,20 @@ ## Задачи -- [ ] Из качества урока (те же входы, что `LessonLearning.Gain`) писать оценку **2, 3, 4 или 5** -- [ ] Пороги и «когда писать» (раз за слот урока) — в `BehaviorDef` / данных `core` -- [ ] Нет учителя / множитель 0 — явное правило (нет оценки или 2), зафиксировать в данных и тесте -- [ ] Хранение на ученике, потолок недавних; сейв -- [ ] Детерминизм: тот же сид и те же входы → та же оценка -- [ ] Игрок оценку не ставит и не правит (нет API правки) +- [x] Из качества урока (те же входы, что `LessonLearning.Gain`) писать оценку **2, 3, 4 или 5** +- [x] Пороги и «когда писать» (раз за слот урока) — в `BehaviorDef` / данных `core` +- [x] Нет учителя / множитель 0 — явное правило (нет оценки или 2), зафиксировать в данных и тесте +- [x] Хранение на ученике, потолок недавних; сейв +- [x] Детерминизм: тот же сид и те же входы → та же оценка +- [x] Игрок оценку не ставит и не правит (нет API правки) ## Тесты, без которых фаза не закрыта -- [ ] Урок с сильным учителем и полными множителями даёт не ниже урока с пустым кабинетом учителя +- [x] Урок с сильным учителем и полными множителями даёт не ниже урока с пустым кабинетом учителя при тех же прочих -- [ ] За один слот не пишется пачка оценок каждую минуту -- [ ] Сейв сохраняет последние оценки -- [ ] HTTP/сокет не принимает «поставить оценку» +- [x] За один слот не пишется пачка оценок каждую минуту +- [x] Сейв сохраняет последние оценки +- [x] HTTP/сокет не принимает «поставить оценку» ## Критерий готовности diff --git a/src/HSchool.Ai/LessonLearning.cs b/src/HSchool.Ai/LessonLearning.cs index 30cede7..3093cad 100644 --- a/src/HSchool.Ai/LessonLearning.cs +++ b/src/HSchool.Ai/LessonLearning.cs @@ -85,11 +85,45 @@ public static class LessonLearning var delta = share * lessonSkillPerHour * hours - * NeedFactor(hunger) - * NeedFactor(warmth) - * TraitFactor(traitOffset) - * TeacherFactor(teacherSkill) - * textbookFactor; + * Quality(hunger, traitOffset, teacherSkill, warmth, textbookFactor); return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max); } + + /// + /// Normalized lesson quality from the same multipliers as (without share, + /// rate and hours). Used to pick a school mark 2–5. + /// + public static float Quality( + float hunger, + int traitOffset, + float teacherSkill, + float warmth = 1f, + float textbookFactor = 1f) => + NeedFactor(hunger) + * NeedFactor(warmth) + * TraitFactor(traitOffset) + * TeacherFactor(teacherSkill) + * textbookFactor; + + /// + /// Maps to 2–5 via + /// (floors for 5, 4, 3 in descending order; below the last → 2). + /// + public static int Mark(float quality, BehaviorDef rules) + { + ArgumentNullException.ThrowIfNull(rules); + var thresholds = rules.LessonMarkThresholds.Count > 0 + ? rules.LessonMarkThresholds + : BehaviorDef.DefaultLessonMarkThresholds; + var value = quality; + for (var i = 0; i < thresholds.Count && i < 3; i++) + { + if (value >= thresholds[i]) + { + return 5 - i; + } + } + + return 2; + } } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index e2e1bbe..6c5e042 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -665,6 +665,45 @@ internal static class PeopleDefValidator throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryKinds cannot contain empty ids."); } } + + if (behavior.LessonMarkMax < 0) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonMarkMax cannot be negative."); + } + + if (behavior.LessonMarkWhenNoTeacher is { } absent + && absent is < 2 or > 5) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonMarkWhenNoTeacher must be 2–5 or null."); + } + + if (behavior.LessonMarkThresholds.Count > 0) + { + if (behavior.LessonMarkThresholds.Count != 3) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must list three floors (5, 4, 3)."); + } + + float? previous = null; + foreach (var floor in behavior.LessonMarkThresholds) + { + if (floor is < 0f or > 1f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must be 0–1."); + } + + if (previous is { } prior && floor > prior) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must be descending."); + } + + previous = floor; + } + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 3f84a20..29375d9 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -584,6 +584,30 @@ public sealed class BehaviorDef : Def "reprimand", ]; + /// + /// How many recent lesson marks a person keeps. Oldest drop first. Zero disables marks. + /// + public int LessonMarkMax { get; init; } = 40; + + /// + /// Inclusive quality floors for marks 5, 4, 3 (descending). Below the last → 2. + /// Empty falls back to . + /// + public IReadOnlyList LessonMarkThresholds { get; init; } = DefaultLessonMarkThresholds; + + /// + /// Mark written when the assigned teacher is not standing in the lesson room. + /// Null = skip the mark (no grade for air). Vanilla writes 2. + /// + public int? LessonMarkWhenNoTeacher { get; init; } = 2; + + public static IReadOnlyList DefaultLessonMarkThresholds { get; } = + [ + 0.85f, + 0.6f, + 0.35f, + ]; + public static IReadOnlyList DefaultOpinionBands { get; } = [ new() { Min = 70, Id = "OpinionCloseFriend" }, diff --git a/src/HSchool.People/LessonMarks.cs b/src/HSchool.People/LessonMarks.cs new file mode 100644 index 0000000..6d00697 --- /dev/null +++ b/src/HSchool.People/LessonMarks.cs @@ -0,0 +1,59 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// One lesson mark (2–5). Sparse list on the person — not a year-long gradebook. +public sealed class LessonMarkRecord +{ + public required string Subject { get; init; } + + public required int Value { get; init; } + + public required DateTime Time { get; init; } + + public required int Period { get; init; } +} + +/// Appends recent lesson marks and drops the oldest when over the BehaviorDef ceiling. +public static class LessonMarkMemory +{ + public static bool Record( + Person person, + string subject, + int value, + DateTime time, + int period, + BehaviorDef rules) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(rules); + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + + if (rules.LessonMarkMax <= 0 || value is < 2 or > 5) + { + return false; + } + + var list = person.LessonMarks; + if (list is null) + { + list = []; + person.LessonMarks = list; + } + + list.Add(new LessonMarkRecord + { + Subject = subject, + Value = value, + Time = DateTime.SpecifyKind(time, DateTimeKind.Utc), + Period = period, + }); + + while (list.Count > rules.LessonMarkMax) + { + list.RemoveAt(0); + } + + return true; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index e27553e..783e899 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -75,6 +75,12 @@ public sealed record Person /// public List? Offenses { get; set; } + /// + /// Recent lesson marks (2–5). Null when empty so people.json stays compact. + /// Ceiling and thresholds live on BehaviorDef. + /// + public List? LessonMarks { get; set; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 8480075..ea0a972 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -99,4 +99,10 @@ // Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score. "offenseMemoryMax": 5, "offenseMemoryKinds": ["quarrel", "fight", "reprimand"], + // Lesson marks 2–5 from lesson quality (slice 13 phase 73). One mark per slot. + "lessonMarkMax": 40, + // Inclusive quality floors for 5, 4, 3 (descending). Below the last → 2. + "lessonMarkThresholds": [0.85, 0.6, 0.35], + // Teacher not in the room: write 2 (null would skip the mark entirely). + "lessonMarkWhenNoTeacher": 2, } diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index da21f4b..19c3780 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -9,8 +9,9 @@ namespace HSchool.Simulation; /// /// 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 . The formula -/// lives in . +/// textbook in the bag learns at . Writes one +/// mark 2–5 per lesson slot from the same quality inputs. The formula lives in +/// . /// internal static class LessonLearningSystem { @@ -69,7 +70,7 @@ internal static class LessonLearningSystem var personId = identity.Id; var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal)); - if (person is null) + if (person is null || !person.IsStudent) { return; } @@ -86,6 +87,7 @@ internal static class LessonLearningSystem if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId)) { school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTeacher, lesson.Subject); + TryWriteAbsentTeacherMark(school, person, lesson, rules); return; } @@ -97,7 +99,7 @@ internal static class LessonLearningSystem } var textbookFactor = 1f; - if (person.IsStudent && !LessonLearning.HasTextbookInBag(person.Items, lesson.Subject)) + if (!LessonLearning.HasTextbookInBag(person.Items, lesson.Subject)) { textbookFactor = rules.LessonNoTextbookFactor; school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTextbook, lesson.Subject); @@ -109,6 +111,7 @@ internal static class LessonLearningSystem ? found : new Dictionary(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) @@ -133,9 +136,57 @@ internal static class LessonLearningSystem 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 (!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; @@ -170,6 +221,19 @@ internal static class LessonLearningSystem 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; diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index b1e7d00..483f6cf 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -18,6 +18,7 @@ public sealed class School : IDisposable private bool _disposed; private readonly List _dayLog = []; private readonly HashSet _lessonLogOnce = new(StringComparer.Ordinal); + private readonly HashSet _lessonMarkOnce = new(StringComparer.Ordinal); private readonly List _worldEvents = []; internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map) @@ -153,6 +154,7 @@ public sealed class School : IDisposable _dayLog.Clear(); LoggedActivity.Clear(); _lessonLogOnce.Clear(); + _lessonMarkOnce.Clear(); } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); @@ -172,6 +174,15 @@ public sealed class School : IDisposable return true; } + /// + /// One lesson mark per person per calendar day and period. Survives teacher arriving mid-slot. + /// + internal bool TryClaimLessonMark(string personId, DateTime day, int period) + { + var key = string.Concat(personId, "\0", day.ToString("yyyy-MM-dd"), "\0", period.ToString()); + return _lessonMarkOnce.Add(key); + } + public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/tests/HSchool.Ai.Tests/LessonMarkTests.cs b/tests/HSchool.Ai.Tests/LessonMarkTests.cs new file mode 100644 index 0000000..3f18475 --- /dev/null +++ b/tests/HSchool.Ai.Tests/LessonMarkTests.cs @@ -0,0 +1,65 @@ +using HSchool.Content; + +namespace HSchool.Ai.Tests; + +public class LessonMarkTests +{ + private static readonly BehaviorDef Rules = new() + { + DefName = "Behavior", + LessonMarkThresholds = BehaviorDef.DefaultLessonMarkThresholds, + LessonMarkMax = 40, + LessonMarkWhenNoTeacher = 2, + }; + + [Fact] + public void StrongTeacher_MarkIsAtLeastEmptyRoomMark() + { + var strongQuality = LessonLearning.Quality( + hunger: 1f, + traitOffset: 0, + teacherSkill: 100f, + warmth: 1f, + textbookFactor: 1f); + var strong = LessonLearning.Mark(strongQuality, Rules); + var emptyRoom = Rules.LessonMarkWhenNoTeacher!.Value; + + Assert.True(strong >= emptyRoom); + Assert.Equal(5, strong); + Assert.Equal(2, emptyRoom); + } + + [Fact] + public void SameInputs_SameMark() + { + var first = LessonLearning.Mark( + LessonLearning.Quality(0.8f, 4, 70f, 0.9f, 0.5f), + Rules); + var second = LessonLearning.Mark( + LessonLearning.Quality(0.8f, 4, 70f, 0.9f, 0.5f), + Rules); + + Assert.Equal(first, second); + Assert.InRange(first, 2, 5); + } + + [Fact] + public void ZeroMultiplier_YieldsLowestMark() + { + var mark = LessonLearning.Mark( + LessonLearning.Quality(1f, 0, 100f, 1f, textbookFactor: 0f), + Rules); + + Assert.Equal(2, mark); + } + + [Theory] + [InlineData(0.85f, 5)] + [InlineData(0.6f, 4)] + [InlineData(0.35f, 3)] + [InlineData(0.34f, 2)] + public void Thresholds_MapQualityToMark(float quality, int expected) + { + Assert.Equal(expected, LessonLearning.Mark(quality, Rules)); + } +} diff --git a/tests/HSchool.AppHost.Tests/LessonMarkApiTests.cs b/tests/HSchool.AppHost.Tests/LessonMarkApiTests.cs new file mode 100644 index 0000000..88aa18c --- /dev/null +++ b/tests/HSchool.AppHost.Tests/LessonMarkApiTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class LessonMarkApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task PostMark_IsNotAccepted() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Оценка API", Start, seed: 73); + var page = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people?role=student&pageSize=1", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + Assert.NotEmpty(page.People); + var personId = page.People[0].Id; + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/people/{personId}/marks", + new { subject = "Mathematics", value = 5, period = 1 }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + using var put = await client.PutAsJsonAsync( + $"/api/schools/{school.Id}/people/{personId}/marks", + new { subject = "Mathematics", value = 5, period = 1 }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, put.StatusCode); + } + + private sealed record PeoplePage(IReadOnlyList People); + + private sealed record PersonRow(string Id); +} diff --git a/tests/HSchool.Content.Tests/LessonMarkBehaviorTests.cs b/tests/HSchool.Content.Tests/LessonMarkBehaviorTests.cs new file mode 100644 index 0000000..c05d5e6 --- /dev/null +++ b/tests/HSchool.Content.Tests/LessonMarkBehaviorTests.cs @@ -0,0 +1,43 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class LessonMarkBehaviorTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void MissingLessonMarkFields_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.LessonMarkMax); + Assert.Equal(2, catalog.BehaviorRules.LessonMarkWhenNoTeacher); + Assert.Equal(BehaviorDef.DefaultLessonMarkThresholds, catalog.BehaviorRules.LessonMarkThresholds); + } + + [Fact] + public void AscendingThresholds_FailTheCatalog() + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "lessonMarkThresholds": [0.3, 0.6, 0.9] }"""), + ])); + + Assert.Contains("descending", error.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/HSchool.People.Tests/LessonMarkMemoryTests.cs b/tests/HSchool.People.Tests/LessonMarkMemoryTests.cs new file mode 100644 index 0000000..19409bb --- /dev/null +++ b/tests/HSchool.People.Tests/LessonMarkMemoryTests.cs @@ -0,0 +1,100 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class LessonMarkMemoryTests +{ + [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(LessonMarkMemory.Record(person, "Mathematics", 5, t0, period: 1, rules)); + Assert.True(LessonMarkMemory.Record(person, "Literature", 4, t0.AddMinutes(1), period: 2, rules)); + Assert.True(LessonMarkMemory.Record(person, "History", 3, t0.AddMinutes(2), period: 3, rules)); + + Assert.Equal(2, person.LessonMarks!.Count); + Assert.Equal("Literature", person.LessonMarks[0].Subject); + Assert.Equal("History", person.LessonMarks[1].Subject); + } + + [Fact] + public void RosterJson_RoundTripsLessonMarks_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); + LessonMarkMemory.Record(pupil, "Mathematics", 4, time, period: 1, Rules(max: 40)); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains("\"lessonMarks\"", json, StringComparison.Ordinal); + Assert.Contains("\"value\": 4", json, StringComparison.Ordinal); + Assert.Contains("\"subject\": \"Mathematics\"", json, StringComparison.Ordinal); + + var without = roster.People.First(person => person.LessonMarks is null || person.LessonMarks.Count == 0); + var withoutSlice = PersonJsonSlice(json, without.Id); + Assert.DoesNotContain("\"lessonMarks\"", 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.LessonMarks); + Assert.Single(loadedPupil.LessonMarks!); + Assert.Equal(4, loadedPupil.LessonMarks[0].Value); + Assert.Equal("Mathematics", loadedPupil.LessonMarks[0].Subject); + Assert.Equal(1, loadedPupil.LessonMarks[0].Period); + Assert.Equal(time, loadedPupil.LessonMarks[0].Time); + } + + 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", + LessonMarkMax = max, + LessonMarkThresholds = BehaviorDef.DefaultLessonMarkThresholds, + LessonMarkWhenNoTeacher = 2, + }; + + 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.Protocol.Tests/LessonMarkProtocolTests.cs b/tests/HSchool.Protocol.Tests/LessonMarkProtocolTests.cs new file mode 100644 index 0000000..47c2e55 --- /dev/null +++ b/tests/HSchool.Protocol.Tests/LessonMarkProtocolTests.cs @@ -0,0 +1,14 @@ +using HSchool.Protocol; + +namespace HSchool.Protocol.Tests; + +public class LessonMarkProtocolTests +{ + [Fact] + public void Wire_HasNoClientSetMarkMessage() + { + var names = Enum.GetNames(); + Assert.DoesNotContain(names, name => name.Contains("Mark", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("Grade", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/HSchool.Simulation.Tests/LessonMarkSimulationTests.cs b/tests/HSchool.Simulation.Tests/LessonMarkSimulationTests.cs new file mode 100644 index 0000000..bef36fb --- /dev/null +++ b/tests/HSchool.Simulation.Tests/LessonMarkSimulationTests.cs @@ -0,0 +1,175 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class LessonMarkSimulationTests +{ + 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 StrongTeacher_MarkIsAtLeastEmptyRoom() + { + var (withTeacher, room, pupilId, teacherId) = StaffedMath(); + using (withTeacher) + { + AdvanceTo(withTeacher, LessonStart); + SetPlace(withTeacher, pupilId, room); + SetPlace(withTeacher, teacherId, room); + PlaceTextbook(withTeacher, pupilId, "Mathematics", ItemLocations.Bag); + LessonLearningSystem.Apply(withTeacher, 45); + var strong = MarkOf(withTeacher, pupilId); + Assert.NotNull(strong); + Assert.InRange(strong!.Value, 2, 5); + + var (empty, emptyRoom, emptyPupil, emptyTeacher) = StaffedMath(); + using (empty) + { + AdvanceTo(empty, LessonStart); + SetPlace(empty, emptyPupil, emptyRoom); + SetPlace(empty, emptyTeacher, "restroom-1"); + LessonLearningSystem.Apply(empty, 45); + var absent = MarkOf(empty, emptyPupil); + Assert.NotNull(absent); + Assert.Equal(2, absent!.Value); + Assert.True(strong.Value >= absent.Value); + } + } + } + + [Fact] + public void OneSlot_DoesNotWriteEveryMinute() + { + var (school, room, pupilId, teacherId) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, room); + SetPlace(school, teacherId, room); + PlaceTextbook(school, pupilId, "Mathematics", ItemLocations.Bag); + + LessonLearningSystem.Apply(school, 5); + LessonLearningSystem.Apply(school, 5); + LessonLearningSystem.Apply(school, 5); + + var marks = school.Roster!.People.First(row => row.Id == pupilId).LessonMarks; + Assert.NotNull(marks); + Assert.Single(marks!); + } + } + + [Fact] + public void NoTeacher_WritesConfiguredMark() + { + var (school, room, pupilId, teacherId) = StaffedMath(); + using (school) + { + Assert.Equal(2, school.Catalog!.BehaviorRules!.LessonMarkWhenNoTeacher); + AdvanceTo(school, LessonStart); + SetPlace(school, pupilId, room); + SetPlace(school, teacherId, "restroom-1"); + + LessonLearningSystem.Apply(school, 45); + + var mark = MarkOf(school, pupilId); + Assert.NotNull(mark); + Assert.Equal(2, mark!.Value); + Assert.Equal("Mathematics", mark.Subject); + } + } + + 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( + string? teacherId = null) + { + 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", teacherId ?? 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) + { + 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, location, subject)); + } + + 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)) + { + presence = new Presence(node, remaining, node, HeadingHome: false, path ?? []); + } + }); + } + + 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); + } +} From 3d8e106096456f4e5a11d08960c5d0aa7c6d29dd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 11:45:51 +0300 Subject: [PATCH 2/2] Mark phase 73 done. Co-authored-by: Cursor --- docs/phases/13-grades/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/13-grades/README.md b/docs/phases/13-grades/README.md index 7f5ac4c..209d4f2 100644 --- a/docs/phases/13-grades/README.md +++ b/docs/phases/13-grades/README.md @@ -11,7 +11,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [73. Оценка с урока](73-lesson-marks.md) | 🔄 | 2–5 из качества урока, сейв | +| [73. Оценка с урока](73-lesson-marks.md) | ✅ | 2–5 из качества урока, сейв | **Этап B — явка.**