Add lesson marks 2-5 from lesson quality once per slot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 11:45:14 +03:00
co-authored by Cursor
parent 4c8d91c6f3
commit b359bebb41
16 changed files with 702 additions and 21 deletions
+39 -5
View File
@@ -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);
}
/// <summary>
/// Normalized lesson quality from the same multipliers as <see cref="Gain"/> (without share,
/// rate and hours). Used to pick a school mark 25.
/// </summary>
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;
/// <summary>
/// Maps <see cref="Quality"/> to 25 via <see cref="BehaviorDef.LessonMarkThresholds"/>
/// (floors for 5, 4, 3 in descending order; below the last → 2).
/// </summary>
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;
}
}
+39
View File
@@ -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 25 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 01.");
}
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)
+24
View File
@@ -584,6 +584,30 @@ public sealed class BehaviorDef : Def
"reprimand",
];
/// <summary>
/// How many recent lesson marks a person keeps. Oldest drop first. Zero disables marks.
/// </summary>
public int LessonMarkMax { get; init; } = 40;
/// <summary>
/// Inclusive quality floors for marks 5, 4, 3 (descending). Below the last → 2.
/// Empty falls back to <see cref="DefaultLessonMarkThresholds"/>.
/// </summary>
public IReadOnlyList<float> LessonMarkThresholds { get; init; } = DefaultLessonMarkThresholds;
/// <summary>
/// Mark written when the assigned teacher is not standing in the lesson room.
/// Null = skip the mark (no grade for air). Vanilla writes 2.
/// </summary>
public int? LessonMarkWhenNoTeacher { get; init; } = 2;
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
[
0.85f,
0.6f,
0.35f,
];
public static IReadOnlyList<OpinionBand> DefaultOpinionBands { get; } =
[
new() { Min = 70, Id = "OpinionCloseFriend" },
+59
View File
@@ -0,0 +1,59 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>One lesson mark (25). Sparse list on the person — not a year-long gradebook.</summary>
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; }
}
/// <summary>Appends recent lesson marks and drops the oldest when over the BehaviorDef ceiling.</summary>
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;
}
}
+6
View File
@@ -75,6 +75,12 @@ public sealed record Person
/// </summary>
public List<OffenseRecord>? Offenses { get; set; }
/// <summary>
/// Recent lesson marks (25). Null when empty so people.json stays compact.
/// Ceiling and thresholds live on <c>BehaviorDef</c>.
/// </summary>
public List<LessonMarkRecord>? LessonMarks { get; set; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
@@ -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 25 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,
}
+68 -4
View File
@@ -9,8 +9,9 @@ namespace HSchool.Simulation;
/// <summary>
/// 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 <see cref="BehaviorDef.LessonNoTextbookFactor"/>. The formula
/// lives in <see cref="HSchool.Ai.LessonLearning"/>.
/// textbook in the bag learns at <see cref="BehaviorDef.LessonNoTextbookFactor"/>. Writes one
/// mark 25 per lesson slot from the same quality inputs. The formula lives in
/// <see cref="HSchool.Ai.LessonLearning"/>.
/// </summary>
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<string, float>(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;
+11
View File
@@ -18,6 +18,7 @@ public sealed class School : IDisposable
private bool _disposed;
private readonly List<PersonLogEvent> _dayLog = [];
private readonly HashSet<string> _lessonLogOnce = new(StringComparer.Ordinal);
private readonly HashSet<string> _lessonMarkOnce = new(StringComparer.Ordinal);
private readonly List<WorldEvent> _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;
}
/// <summary>
/// One lesson mark per person per calendar day and period. Survives teacher arriving mid-slot.
/// </summary>
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);