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/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 — явка.**
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 fb1fa18..4fb0aed 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -683,6 +683,45 @@ internal static class PeopleDefValidator
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
}
+
+ 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 02a7fc6..b65078a 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -602,6 +602,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 1a8eeaa..d03d435 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -104,4 +104,10 @@
"directorSummonReprimandChance": 0.35,
"directorHearingMinutes": 5,
"directorHearingOpinionShift": -12,
+ // 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 1993718..e6aee17 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)
@@ -160,6 +161,7 @@ public sealed class School : IDisposable
_dayLog.Clear();
LoggedActivity.Clear();
_lessonLogOnce.Clear();
+ _lessonMarkOnce.Clear();
}
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
@@ -179,6 +181,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);
+ }
+}