Merge branch 'phase/73-lesson-marks'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 2–5.
|
||||
/// </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 2–5 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -602,6 +602,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" },
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>One lesson mark (2–5). 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;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ public sealed record Person
|
||||
/// </summary>
|
||||
public List<OffenseRecord>? Offenses { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recent lesson marks (2–5). 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 2–5 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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
Reference in New Issue
Block a user