diff --git a/docs/phases/12-office/72-parent-meetings.md b/docs/phases/12-office/72-parent-meetings.md
index 87e50de..276664c 100644
--- a/docs/phases/12-office/72-parent-meetings.md
+++ b/docs/phases/12-office/72-parent-meetings.md
@@ -12,26 +12,26 @@
## Задачи
-- [ ] Только ИИ: редкий бросок от сида класса и дня; кнопки игрока нет
-- [ ] Условия: есть классный, рабочий день, после последнего урока **этого** класса, классный в
+- [x] Только ИИ: редкий бросок от сида класса и дня; кнопки игрока нет
+- [x] Условия: есть классный, рабочий день, после последнего урока **этого** класса, классный в
школе; пороги в `BehaviorDef`
-- [ ] Классный остаётся в homeroom класса
-- [ ] Доля родителей класса приходит: получают узел (двор → путь → кабинет), обычная ходьба
-- [ ] Учитель-родитель ученика класса, уже в школе, может участвовать без второго инстанса
-- [ ] Собрание — ограниченное по времени действие/занятость узла; лёгкий сдвиг мнений
+- [x] Классный остаётся в homeroom класса
+- [x] Доля родителей класса приходит: получают узел (двор → путь → кабинет), обычная ходьба
+- [x] Учитель-родитель ученика класса, уже в школе, может участвовать без второго инстанса
+- [x] Собрание — ограниченное по времени действие/занятость узла; лёгкий сдвиг мнений
семья↔классный
-- [ ] После — родители снова «вне школы»; классный свободен
-- [ ] `EventDef` info «собрание»; локали
-- [ ] Нет классного — собрание не планируется
+- [x] После — родители снова «вне школы»; классный свободен
+- [x] `EventDef` info «собрание»; локали
+- [x] Нет классного — собрание не планируется
## Тесты, без которых фаза не закрыта
-- [ ] Без `classTeacherId` собрание не стартует при том же сиде дня
-- [ ] С классным и прохождением редкости родители появляются на карте и уходят после
-- [ ] Собрание не раньше последнего урока класса
-- [ ] Тот же сид класса/дня → те же «кто пришёл» (детерминизм доли)
-- [ ] Игрок не назначает собрание по API
-- [ ] Skip ночи не оставляет родителей вечно в кабинете
+- [x] Без `classTeacherId` собрание не стартует при том же сиде дня
+- [x] С классным и прохождением редкости родители появляются на карте и уходят после
+- [x] Собрание не раньше последнего урока класса
+- [x] Тот же сид класса/дня → те же «кто пришёл» (детерминизм доли)
+- [x] Игрок не назначает собрание по API
+- [x] Skip ночи не оставляет родителей вечно в кабинете
## Критерий готовности
diff --git a/src/HSchool.Ai/ParentMeetings.cs b/src/HSchool.Ai/ParentMeetings.cs
new file mode 100644
index 0000000..1be87be
--- /dev/null
+++ b/src/HSchool.Ai/ParentMeetings.cs
@@ -0,0 +1,129 @@
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Ai;
+
+///
+/// Pure helpers for AI class-teacher parent meetings. Session state lives on the school worker.
+///
+public static class ParentMeetings
+{
+ public const string MeetingAction = "ParentMeeting";
+
+ /// Presence / card label while walking to the homeroom. Not a planner pick.
+ public const string GoingAction = "GoingToParentMeeting";
+
+ public static DateTime? LastLessonEnd(
+ DefCatalog catalog,
+ Timetable? timetable,
+ SchoolClass schoolClass,
+ DateTime time)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(schoolClass);
+ if (catalog.DayFrame is null || timetable is null)
+ {
+ return null;
+ }
+
+ var weekday = SchoolDay.WeekdayIndex(time);
+ var lastPeriod = timetable.Lessons
+ .Where(lesson =>
+ lesson.ClassId.Equals(schoolClass.Id, StringComparison.Ordinal)
+ && lesson.Day == weekday)
+ .Select(lesson => lesson.Period)
+ .DefaultIfEmpty(0)
+ .Max();
+ if (lastPeriod < 1)
+ {
+ return null;
+ }
+
+ var end = SchoolDay.PeriodEnd(catalog.DayFrame, lastPeriod);
+ var day = DateTime.SpecifyKind(time, DateTimeKind.Utc).Date;
+ return day + end.ToTimeSpan();
+ }
+
+ public static bool IsAfterLastLesson(
+ DefCatalog catalog,
+ Timetable? timetable,
+ SchoolClass schoolClass,
+ DateTime time,
+ int weekDays)
+ {
+ if (!SchoolDay.IsWorkday(catalog, time, weekDays))
+ {
+ return false;
+ }
+
+ var end = LastLessonEnd(catalog, timetable, schoolClass, time);
+ if (end is null)
+ {
+ return false;
+ }
+
+ return DateTime.SpecifyKind(time, DateTimeKind.Utc) >= end.Value;
+ }
+
+ public static bool RollMeeting(float chance, int seed) =>
+ chance >= 1f || (chance > 0f && TalkCircles.Roll01(seed) < chance);
+
+ ///
+ /// Unique parents of the class pupils, ordinal order. Teacher-parents stay in the list — one
+ /// roster id, no second instance.
+ ///
+ public static IReadOnlyList ClassParentIds(Roster roster, SchoolClass schoolClass)
+ {
+ ArgumentNullException.ThrowIfNull(roster);
+ ArgumentNullException.ThrowIfNull(schoolClass);
+
+ var pupilSet = schoolClass.PupilIds.ToHashSet(StringComparer.Ordinal);
+ var parents = new HashSet(StringComparer.Ordinal);
+ foreach (var family in roster.Families)
+ {
+ if (!family.ChildIds.Any(childId => pupilSet.Contains(childId)))
+ {
+ continue;
+ }
+
+ foreach (var parentId in family.ParentIds)
+ {
+ parents.Add(parentId);
+ }
+ }
+
+ return parents.OrderBy(id => id, StringComparer.Ordinal).ToArray();
+ }
+
+ public static IReadOnlyList SelectAttendees(
+ Roster roster,
+ SchoolClass schoolClass,
+ float fraction,
+ int schoolSeed,
+ int dayNumber)
+ {
+ var parents = ClassParentIds(roster, schoolClass);
+ if (parents.Count == 0 || fraction <= 0f)
+ {
+ return [];
+ }
+
+ if (fraction >= 1f)
+ {
+ return parents;
+ }
+
+ var chosen = new List();
+ foreach (var parentId in parents)
+ {
+ var seed = Seed.Mix(schoolSeed, parentId, dayNumber, Seed.MeetingAttendSalt);
+ if (TalkCircles.Roll01(seed) < fraction)
+ {
+ chosen.Add(parentId);
+ }
+ }
+
+ return chosen;
+ }
+}
diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts
index cc2df11..352e8d1 100644
--- a/src/HSchool.Client/src/i18n/strings.ts
+++ b/src/HSchool.Client/src/i18n/strings.ts
@@ -402,6 +402,7 @@ const ru = {
LessonStarted: 'Начало урока',
GenerationFailed: 'Не удалось нарисовать портрет',
DirectorSummoned: 'Ученика вызвали к директору',
+ ParentMeeting: 'Идёт родительское собрание',
noticeDismiss: 'Закрыть',
noticeGenerateImage: 'Создать картинку',
noticeGenerateImageBusy: 'Рисуем…',
@@ -812,6 +813,7 @@ const en: Messages = {
LessonStarted: 'A lesson has started',
GenerationFailed: 'Portrait generation failed',
DirectorSummoned: 'A pupil was summoned to the principal',
+ ParentMeeting: 'A parent meeting is under way',
noticeDismiss: 'Close',
noticeGenerateImage: 'Create image',
noticeGenerateImageBusy: 'Drawing…',
diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts
index 1353c86..53a7502 100644
--- a/src/HSchool.Client/src/ui/noticeToasts.ts
+++ b/src/HSchool.Client/src/ui/noticeToasts.ts
@@ -10,6 +10,7 @@ export function noticeLabel(defName: string): string {
case 'LessonStarted':
case 'GenerationFailed':
case 'DirectorSummoned':
+ case 'ParentMeeting':
return t(defName satisfies MessageKey);
default:
return defName;
diff --git a/src/HSchool.Content/EventDefValidator.cs b/src/HSchool.Content/EventDefValidator.cs
index 44da79b..5c4b3e7 100644
--- a/src/HSchool.Content/EventDefValidator.cs
+++ b/src/HSchool.Content/EventDefValidator.cs
@@ -15,6 +15,7 @@ internal static class EventDefValidator
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
+ EventTriggers.ParentMeeting,
};
private static readonly HashSet Actions = new(StringComparer.Ordinal)
diff --git a/src/HSchool.Content/EventDefs.cs b/src/HSchool.Content/EventDefs.cs
index d21f653..a011bee 100644
--- a/src/HSchool.Content/EventDefs.cs
+++ b/src/HSchool.Content/EventDefs.cs
@@ -13,6 +13,7 @@ public static class EventTriggers
public const string LessonStart = "lessonStart";
public const string GenerationFailed = "generationFailed";
public const string DirectorSummon = "directorSummon";
+ public const string ParentMeeting = "parentMeeting";
}
public static class EventActions
diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs
index ffdcb28..ca4277c 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -684,6 +684,24 @@ internal static class PeopleDefValidator
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
}
+ if (behavior.ParentMeetingChance is < 0f or > 1f)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' parentMeetingChance must be 0–1.");
+ }
+
+ if (behavior.ParentMeetingAttendanceFraction is < 0f or > 1f)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' parentMeetingAttendanceFraction must be 0–1.");
+ }
+
+ if (behavior.ParentMeetingMinutes <= 0f)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' parentMeetingMinutes must be positive.");
+ }
+
if (behavior.LessonMarkMax < 0)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonMarkMax cannot be negative.");
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 29fa813..953f0fb 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -595,6 +595,25 @@ public sealed class BehaviorDef : Def
/// Opinion of the Principal after a hearing. Negative — the visit is not praise.
public int DirectorHearingOpinionShift { get; init; } = -12;
+ ///
+ /// Chance a class with a class teacher holds a parent meeting after its last lesson.
+ /// Low in vanilla — meetings are rare AI, not a weekly calendar.
+ ///
+ public float ParentMeetingChance { get; init; } = 0.08f;
+
+ ///
+ /// Independent chance each class parent is invited when a meeting starts. Not 100% attendance.
+ ///
+ public float ParentMeetingAttendanceFraction { get; init; } = 0.4f;
+
+ /// Game minutes the class teacher keeps the homeroom for the meeting.
+ public float ParentMeetingMinutes { get; init; } = 20f;
+
+ ///
+ /// Opinion shift both ways between each attending parent and the class teacher. Small and positive.
+ ///
+ public int ParentMeetingOpinionShift { get; init; } = 3;
+
public static IReadOnlyList DefaultOffenseMemoryKinds { get; } =
[
"quarrel",
diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs
index 306b8f9..7b0d97e 100644
--- a/src/HSchool.People/Seed.cs
+++ b/src/HSchool.People/Seed.cs
@@ -23,6 +23,8 @@ public static class Seed
public const int HomeSalt = 15;
public const int SummonSalt = 16;
public const int HealthSalt = 17;
+ public const int MeetingSalt = 18;
+ public const int MeetingAttendSalt = 19;
/// 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/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs
index 841a966..b06f66e 100644
--- a/src/HSchool.Server/Game/PersonCardReader.cs
+++ b/src/HSchool.Server/Game/PersonCardReader.cs
@@ -62,7 +62,8 @@ internal static partial class PersonCardReader
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
var skills = LiveSkills(school.World, personId);
var activityId = LiveActivity(school.World, personId)
- ?? school.DirectorSummonPresenceActionId(personId);
+ ?? school.DirectorSummonPresenceActionId(personId)
+ ?? school.ParentMeetingPresenceActionId(personId);
string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
{
diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
index 74a27a0..b289c45 100644
--- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc
+++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
@@ -155,6 +155,22 @@
"roles": ["student"],
"weight": 0,
},
+ {
+ // Class-teacher parent meeting (slice 12 phase 72). Weight 0 — planner never picks it;
+ // ParentMeetingSystem starts it when the teacher is idle in the homeroom.
+ "defName": "ParentMeeting",
+ "room": "Classroom",
+ "minutes": 20,
+ "roles": ["staff"],
+ "weight": 0,
+ },
+ {
+ "defName": "GoingToParentMeeting",
+ "room": "Classroom",
+ "minutes": 1,
+ "roles": ["staff", "parent"],
+ "weight": 0,
+ },
{
"defName": "ChangeClothesMale",
"room": "MaleChangingRoom",
diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
index 4874084..fc67ee3 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -104,6 +104,11 @@
"directorSummonReprimandChance": 0.35,
"directorHearingMinutes": 5,
"directorHearingOpinionShift": -12,
+ // Parent meetings (slice 12 phase 72). Rare AI after the class's last lesson.
+ "parentMeetingChance": 0.08,
+ "parentMeetingAttendanceFraction": 0.4,
+ "parentMeetingMinutes": 20,
+ "parentMeetingOpinionShift": 3,
// 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.
diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
index 3139ba0..ac1d731 100644
--- a/src/HSchool.Server/mods/core/defs/events/notices.jsonc
+++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
@@ -31,4 +31,12 @@
"trigger": "directorSummon",
"action": "none",
},
+ {
+ "defName": "ParentMeeting",
+ "severity": "info",
+ "pause": false,
+ "ttlMs": 8000,
+ "trigger": "parentMeeting",
+ "action": "none",
+ },
]
diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc
index a5df004..7910552 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -232,7 +232,9 @@
"LessonStarted": "A lesson has started",
"GenerationFailed": "Portrait generation failed",
"DirectorSummoned": "A pupil was summoned to the principal",
+ "ParentMeeting": "A parent meeting is under way",
"GoingToPrincipal": "going to the principal",
"WaitForPrincipal": "waiting at the principal's office",
+ "GoingToParentMeeting": "going to a parent meeting",
"core": "Core",
}
diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc
index 36bb33b..a2f673d 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -232,7 +232,9 @@
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
+ "ParentMeeting": "Идёт родительское собрание",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
+ "GoingToParentMeeting": "идёт на собрание",
"core": "Базовая игра",
}
diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs
index a135e13..4886756 100644
--- a/src/HSchool.Simulation/ActivitySystem.cs
+++ b/src/HSchool.Simulation/ActivitySystem.cs
@@ -158,6 +158,11 @@ internal static class ActivitySystem
DirectorSummonSystem.CompleteHearing(school, identity.Id);
}
+ if (IsParentMeeting(action.DefName))
+ {
+ ParentMeetingSystem.CompleteMeeting(school, identity.Id);
+ }
+
activity = PersonActivity.Idle;
completed.Add(identity.Id);
});
@@ -190,6 +195,10 @@ internal static class ActivitySystem
actionId is not null
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
+ internal static bool IsParentMeeting(string? actionId) =>
+ actionId is not null
+ && actionId.Equals(ParentMeetings.MeetingAction, StringComparison.Ordinal);
+
private static int Occupied(School school, string nodeId, string thing)
{
var occupied = 0;
diff --git a/src/HSchool.Simulation/ParentMeetingSystem.cs b/src/HSchool.Simulation/ParentMeetingSystem.cs
new file mode 100644
index 0000000..0b94807
--- /dev/null
+++ b/src/HSchool.Simulation/ParentMeetingSystem.cs
@@ -0,0 +1,451 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+
+namespace HSchool.Simulation;
+
+///
+/// Rare AI parent meetings after a class's last lesson. Only the school worker mutates
+/// .
+///
+internal static class ParentMeetingSystem
+{
+ private static readonly QueryDescription PresenceQuery =
+ new QueryDescription().WithAll();
+
+ public static bool IsInMeeting(School school, string personId) =>
+ school.ActiveParentMeetings.Any(session =>
+ session.TeacherId.Equals(personId, StringComparison.Ordinal)
+ || session.AttendeeIds.Contains(personId));
+
+ public static bool TryDutyRoom(School school, string personId, out string roomId)
+ {
+ roomId = null!;
+ var session = school.ActiveParentMeetings.FirstOrDefault(row =>
+ row.TeacherId.Equals(personId, StringComparison.Ordinal)
+ || row.AttendeeIds.Contains(personId));
+ if (session is null)
+ {
+ return false;
+ }
+
+ roomId = session.HomeroomId;
+ return true;
+ }
+
+ public static void Apply(School school)
+ {
+ if (school.Roster is null || school.Map is null || school.Catalog is null)
+ {
+ return;
+ }
+
+ TryStartMeetings(school);
+ foreach (var session in school.ActiveParentMeetings.ToArray())
+ {
+ TryStartMeetingAction(school, session);
+ }
+ }
+
+ ///
+ /// Start a meeting for one class when rarity and attendance pass. Used by tests with chance 1.
+ ///
+ public static bool TryStart(
+ School school,
+ string classId,
+ float chance,
+ float fraction)
+ {
+ if (school.Roster is null || school.Catalog is null)
+ {
+ return false;
+ }
+
+ var schoolClass = school.Roster.Classes.FirstOrDefault(row =>
+ row.Id.Equals(classId, StringComparison.Ordinal));
+ if (schoolClass?.ClassTeacherId is null
+ || string.IsNullOrWhiteSpace(schoolClass.RoomId))
+ {
+ return false;
+ }
+
+ if (school.ActiveParentMeetings.Any(row =>
+ row.ClassId.Equals(classId, StringComparison.Ordinal)))
+ {
+ return false;
+ }
+
+ if (!ParentMeetings.IsAfterLastLesson(
+ school.Catalog,
+ school.Timetable,
+ schoolClass,
+ school.Clock.Time,
+ school.SchoolWeekDays))
+ {
+ return false;
+ }
+
+ if (!IsOnCampus(school, schoolClass.ClassTeacherId))
+ {
+ return false;
+ }
+
+ var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
+ school.ParentMeetingRolledToday.Add(schoolClass.Id);
+ var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
+ if (!ParentMeetings.RollMeeting(chance, seed))
+ {
+ return false;
+ }
+
+ var attendees = ParentMeetings.SelectAttendees(
+ school.Roster,
+ schoolClass,
+ fraction,
+ school.PeopleSeed,
+ dayNumber);
+ if (attendees.Count == 0)
+ {
+ return false;
+ }
+
+ BeginSession(school, schoolClass, attendees);
+ return true;
+ }
+
+ public static void ClearAll(School school)
+ {
+ school.ParentMeetingRolledToday.Clear();
+ if (school.ActiveParentMeetings.Count == 0)
+ {
+ return;
+ }
+
+ var ids = school.ActiveParentMeetings
+ .SelectMany(session => session.AttendeeIds.Append(session.TeacherId))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ school.ActiveParentMeetings.Clear();
+ foreach (var id in ids)
+ {
+ ForceOffCampusIfParentOnly(school, id);
+ PresenceSystem.Enqueue(school, id);
+ }
+ }
+
+ public static void CompleteMeeting(School school, string teacherId)
+ {
+ var session = school.ActiveParentMeetings.FirstOrDefault(row =>
+ row.TeacherId.Equals(teacherId, StringComparison.Ordinal) && row.InProgress);
+ if (session is null)
+ {
+ return;
+ }
+
+ ApplyOpinionShifts(school, session);
+ school.ActiveParentMeetings.Remove(session);
+ foreach (var parentId in session.AttendeeIds)
+ {
+ SendHome(school, parentId);
+ PresenceSystem.Enqueue(school, parentId);
+ }
+
+ PresenceSystem.Enqueue(school, session.TeacherId);
+ }
+
+ private static void TryStartMeetings(School school)
+ {
+ var roster = school.Roster!;
+ var catalog = school.Catalog!;
+ var rules = catalog.BehaviorRules;
+ var chance = rules?.ParentMeetingChance ?? 0.08f;
+ var fraction = rules?.ParentMeetingAttendanceFraction ?? 0.4f;
+ var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
+
+ foreach (var schoolClass in roster.Classes.OrderBy(row => row.Id, StringComparer.Ordinal))
+ {
+ if (schoolClass.ClassTeacherId is null
+ || string.IsNullOrWhiteSpace(schoolClass.RoomId))
+ {
+ continue;
+ }
+
+ if (school.ParentMeetingRolledToday.Contains(schoolClass.Id)
+ || school.ActiveParentMeetings.Any(row =>
+ row.ClassId.Equals(schoolClass.Id, StringComparison.Ordinal)))
+ {
+ continue;
+ }
+
+ if (!ParentMeetings.IsAfterLastLesson(
+ catalog,
+ school.Timetable,
+ schoolClass,
+ school.Clock.Time,
+ school.SchoolWeekDays))
+ {
+ continue;
+ }
+
+ if (!IsOnCampus(school, schoolClass.ClassTeacherId))
+ {
+ continue;
+ }
+
+ school.ParentMeetingRolledToday.Add(schoolClass.Id);
+ var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
+ if (!ParentMeetings.RollMeeting(chance, seed))
+ {
+ continue;
+ }
+
+ var attendees = ParentMeetings.SelectAttendees(
+ roster,
+ schoolClass,
+ fraction,
+ school.PeopleSeed,
+ dayNumber);
+ if (attendees.Count == 0)
+ {
+ continue;
+ }
+
+ BeginSession(school, schoolClass, attendees);
+ }
+ }
+
+ private static void BeginSession(
+ School school,
+ SchoolClass schoolClass,
+ IReadOnlyList attendees)
+ {
+ var session = new ParentMeetingSession(
+ schoolClass.Id,
+ schoolClass.ClassTeacherId!,
+ schoolClass.RoomId,
+ attendees);
+ school.ActiveParentMeetings.Add(session);
+ PresenceSystem.Enqueue(school, session.TeacherId);
+ foreach (var parentId in attendees)
+ {
+ PresenceSystem.Enqueue(school, parentId);
+ }
+
+ school.RaiseWorldEvent(new WorldEvent(EventTriggers.ParentMeeting, session.TeacherId));
+ }
+
+ private static void TryStartMeetingAction(School school, ParentMeetingSession session)
+ {
+ if (session.InProgress || school.Catalog is null)
+ {
+ return;
+ }
+
+ if (!IsIdleInRoom(school, session.TeacherId, session.HomeroomId))
+ {
+ return;
+ }
+
+ // At least one invited parent present and idle — teacher-parents already on campus count.
+ var anyParentHere = session.AttendeeIds.Any(id => IsIdleInRoom(school, id, session.HomeroomId));
+ if (!anyParentHere)
+ {
+ return;
+ }
+
+ if (!school.Catalog.Actions.TryGetValue(ParentMeetings.MeetingAction, out var action)
+ || action.Abstract)
+ {
+ return;
+ }
+
+ var minutes = school.Catalog.BehaviorRules?.ParentMeetingMinutes ?? action.Minutes;
+ if (minutes <= 0f)
+ {
+ minutes = action.Minutes;
+ }
+
+ var started = false;
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
+ {
+ if (started || !identity.Id.Equals(session.TeacherId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (activity.IsActive)
+ {
+ return;
+ }
+
+ activity = new PersonActivity(ParentMeetings.MeetingAction, null, minutes);
+ intent = Intent.None;
+ session.InProgress = true;
+ started = true;
+ });
+ }
+
+ private static void ApplyOpinionShifts(School school, ParentMeetingSession session)
+ {
+ var roster = school.Roster!;
+ var rules = school.Catalog?.BehaviorRules;
+ if (rules is null)
+ {
+ return;
+ }
+
+ var shift = rules.ParentMeetingOpinionShift;
+ if (shift == 0)
+ {
+ return;
+ }
+
+ var teacher = roster.People.FirstOrDefault(row =>
+ row.Id.Equals(session.TeacherId, StringComparison.Ordinal));
+ if (teacher is null)
+ {
+ return;
+ }
+
+ foreach (var parentId in session.AttendeeIds.OrderBy(id => id, StringComparer.Ordinal))
+ {
+ var parent = roster.People.FirstOrDefault(row =>
+ row.Id.Equals(parentId, StringComparison.Ordinal));
+ if (parent is null)
+ {
+ continue;
+ }
+
+ var parentOfTeacher = OpinionStore.Get(parent, session.TeacherId) ?? 0;
+ OpinionStore.Set(parent, session.TeacherId, parentOfTeacher + shift);
+ var teacherOfParent = OpinionStore.Get(teacher, parentId) ?? 0;
+ OpinionStore.Set(teacher, parentId, teacherOfParent + shift);
+ }
+
+ school.RosterTalkDirty = true;
+ }
+
+ private static void SendHome(School school, string personId)
+ {
+ if (school.Walks is null)
+ {
+ ForceOffCampusIfParentOnly(school, personId);
+ return;
+ }
+
+ var person = school.Roster?.People.FirstOrDefault(row =>
+ row.Id.Equals(personId, StringComparison.Ordinal));
+ // Staff who are also parents stay for the rest of their work day.
+ if (person is { IsStaff: true })
+ {
+ return;
+ }
+
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
+ {
+ if (!identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ activity = PersonActivity.Idle;
+ intent = Intent.None;
+ if (!presence.IsOnCampus)
+ {
+ presence = Presence.OffCampus;
+ return;
+ }
+
+ presence = PresenceStepper.StartWalk(
+ presence,
+ school.Walks,
+ school.Walks.TerritoryId,
+ headingHome: true);
+ });
+ }
+
+ private static void ForceOffCampusIfParentOnly(School school, string personId)
+ {
+ var person = school.Roster?.People.FirstOrDefault(row =>
+ row.Id.Equals(personId, StringComparison.Ordinal));
+ if (person is { IsStaff: true } || person is { IsStudent: true })
+ {
+ return;
+ }
+
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
+ {
+ if (!identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ presence = Presence.OffCampus;
+ activity = PersonActivity.Idle;
+ intent = Intent.None;
+ });
+ }
+
+ private static bool IsOnCampus(School school, string personId)
+ {
+ var onCampus = false;
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence, ref PersonActivity _, ref Intent _) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ onCampus = presence.IsOnCampus;
+ }
+ });
+ return onCampus;
+ }
+
+ private static bool IsIdleInRoom(School school, string personId, string roomId)
+ {
+ var found = false;
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
+ {
+ if (!identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
+ || (presence.DestinationId is not null
+ && !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
+ found = !activity.IsActive
+ && !walking
+ && presence.NodeId is not null
+ && presence.NodeId.Equals(roomId, StringComparison.Ordinal);
+ });
+ return found;
+ }
+}
+
+/// One active parent meeting for a class.
+internal sealed class ParentMeetingSession(
+ string classId,
+ string teacherId,
+ string homeroomId,
+ IReadOnlyList attendeeIds)
+{
+ public string ClassId { get; } = classId;
+
+ public string TeacherId { get; } = teacherId;
+
+ public string HomeroomId { get; } = homeroomId;
+
+ public IReadOnlyList AttendeeIds { get; } = attendeeIds;
+
+ public bool InProgress { get; set; }
+}
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
index 864328c..1a2661d 100644
--- a/src/HSchool.Simulation/PresenceSystem.cs
+++ b/src/HSchool.Simulation/PresenceSystem.cs
@@ -331,7 +331,8 @@ internal static class PresenceSystem
var bound = Duty.IsOtherStaff(person)
? slot.Kind != DaySlotKind.Outside
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
- if (DirectorSummonSystem.IsSummoned(school, person.Id))
+ if (DirectorSummonSystem.IsSummoned(school, person.Id)
+ || ParentMeetingSystem.IsInMeeting(school, person.Id))
{
bound = true;
}
@@ -352,9 +353,24 @@ internal static class PresenceSystem
return null;
}
+ if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId))
+ {
+ return null;
+ }
+
if (!presence.IsOnCampus)
{
intent = Intent.None;
+ if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingArrive))
+ {
+ presence = PresenceStepper.StartWalk(
+ Presence.OffCampus,
+ walks,
+ meetingArrive,
+ headingHome: false);
+ return null;
+ }
+
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
{
var dest = plan.FirstRoom ?? Duty.RoomAt(
@@ -379,7 +395,10 @@ internal static class PresenceSystem
return null;
}
- if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id))
+ if (plan.WalkHomeAt is { } leave
+ && now >= leave
+ && !DirectorSummonSystem.IsSummoned(school, person.Id)
+ && !ParentMeetingSystem.IsInMeeting(school, person.Id))
{
activity = PersonActivity.Idle;
intent = Intent.None;
@@ -399,6 +418,11 @@ internal static class PresenceSystem
duty = summonRoom;
bound = true;
}
+ else if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingRoom))
+ {
+ duty = meetingRoom;
+ bound = true;
+ }
if (duty is null)
{
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index 6096437..c5fc3d6 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -153,6 +153,14 @@ public sealed class School : IDisposable
internal int NextSummonOrder { get; set; }
+ ///
+ /// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
+ ///
+ internal List ActiveParentMeetings { get; } = [];
+
+ /// Class ids that already rolled a meeting chance today (pass or fail).
+ internal HashSet ParentMeetingRolledToday { get; } = new(StringComparer.Ordinal);
+
///
/// A finished or interrupted circle wrote opinions onto the roster.
/// returns this so the worker persists people.json — same seam as morning dress.
@@ -314,6 +322,7 @@ public sealed class School : IDisposable
Clock.JumpTo(next.Value);
TalkCircleSystem.AbandonAll(this);
DirectorSummonSystem.ClearAll(this);
+ ParentMeetingSystem.ClearAll(this);
ResetDayLog();
var peopleChanged = TryYearlyIntake(before, next.Value);
peopleChanged |= TryApplicantRefresh();
@@ -388,6 +397,7 @@ public sealed class School : IDisposable
ResetDayLog();
// Hung summons do not survive the work morning — same clear as skip empty.
DirectorSummonSystem.ClearAll(this);
+ ParentMeetingSystem.ClearAll(this);
}
peopleChanged = TryYearlyIntake(before, Clock.Time);
@@ -445,6 +455,29 @@ public sealed class School : IDisposable
public string? DirectorSummonPresenceActionId(string personId) =>
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
+ ///
+ /// Card label while walking to / sitting in a parent meeting without a live activity yet.
+ ///
+ public string? ParentMeetingPresenceActionId(string personId)
+ {
+ if (!ParentMeetingSystem.IsInMeeting(this, personId))
+ {
+ return null;
+ }
+
+ var session = ActiveParentMeetings.FirstOrDefault(row =>
+ row.TeacherId.Equals(personId, StringComparison.Ordinal)
+ || row.AttendeeIds.Contains(personId));
+ if (session is null)
+ {
+ return null;
+ }
+
+ return session.InProgress && session.TeacherId.Equals(personId, StringComparison.Ordinal)
+ ? HSchool.Ai.ParentMeetings.MeetingAction
+ : HSchool.Ai.ParentMeetings.GoingAction;
+ }
+
private void RecordWorldEvents(DateTime before, DateTime after)
{
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
@@ -469,6 +502,7 @@ public sealed class School : IDisposable
}
DirectorSummonSystem.Apply(this);
+ ParentMeetingSystem.Apply(this);
PersonDayLog.Sync(this);
diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs
index 7d0db8a..01f56e6 100644
--- a/src/HSchool.Simulation/TalkCircleSystem.cs
+++ b/src/HSchool.Simulation/TalkCircleSystem.cs
@@ -57,6 +57,7 @@ internal static class TalkCircleSystem
school.ApologyDebts.Clear();
DirectorSummonSystem.ClearAll(school);
+ ParentMeetingSystem.ClearAll(school);
school.World.Query(in People, (ref PersonActivity activity) =>
{
if (TalkActions.IsConflict(activity.ActionId))
diff --git a/tests/HSchool.Content.Tests/ParentMeetingBehaviorTests.cs b/tests/HSchool.Content.Tests/ParentMeetingBehaviorTests.cs
new file mode 100644
index 0000000..7b25511
--- /dev/null
+++ b/tests/HSchool.Content.Tests/ParentMeetingBehaviorTests.cs
@@ -0,0 +1,54 @@
+using HSchool.Content;
+
+namespace HSchool.Content.Tests;
+
+public class ParentMeetingBehaviorTests
+{
+ private readonly CatalogLoader _loader = new();
+
+ [Fact]
+ public void VanillaRules_LoadParentMeetingThresholds()
+ {
+ 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;
+ }
+
+ documents.Add(new ContentDocument(
+ CatalogLoader.CorePackId,
+ Path.GetRelativePath(root, path).Replace('\\', '/'),
+ File.ReadAllText(path)));
+ }
+
+ var catalog = _loader.Load([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(catalog.BehaviorRules);
+ Assert.Equal(0.08f, catalog.BehaviorRules.ParentMeetingChance);
+ Assert.Equal(0.4f, catalog.BehaviorRules.ParentMeetingAttendanceFraction);
+ Assert.Equal(20f, catalog.BehaviorRules.ParentMeetingMinutes);
+ Assert.Equal(3, catalog.BehaviorRules.ParentMeetingOpinionShift);
+ Assert.Contains(
+ catalog.Events.Values,
+ row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ParentMeetingChanceOutOfRange_FailsTheCatalog()
+ {
+ var error = Assert.Throws(() => _loader.Load(
+ [CatalogLoader.CorePackId],
+ [
+ PackDocuments.Def(
+ CatalogLoader.CorePackId,
+ "behavior",
+ "rules",
+ """{ "defName": "Behavior", "parentMeetingChance": 1.5 }"""),
+ ]));
+
+ Assert.Contains("parentMeetingChance", error.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/ParentMeetingTests.cs b/tests/HSchool.Simulation.Tests/ParentMeetingTests.cs
new file mode 100644
index 0000000..8828d2c
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/ParentMeetingTests.cs
@@ -0,0 +1,340 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Protocol;
+using HSchool.Simulation;
+
+namespace HSchool.Simulation.Tests;
+
+public class ParentMeetingTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void WithoutClassTeacher_SameDaySeed_DoesNotStart()
+ {
+ var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: false);
+ using (school)
+ {
+ Assert.Null(schoolClass.ClassTeacherId);
+ Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
+ Assert.Empty(school.ActiveParentMeetings);
+ Assert.DoesNotContain(
+ school.DrainWorldEvents(),
+ row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
+ }
+ }
+
+ [Fact]
+ public void WithClassTeacher_ParentsAppearThenLeaveAfterMeeting()
+ {
+ var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true);
+ using (school)
+ {
+ Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
+ var session = Assert.Single(school.ActiveParentMeetings);
+ Assert.NotEmpty(session.AttendeeIds);
+
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ foreach (var parentId in session.AttendeeIds)
+ {
+ PlaceAt(school, parentId, schoolClass.RoomId);
+ Assert.Equal(schoolClass.RoomId, NodeOf(school, parentId));
+ }
+
+ ParentMeetingSystem.Apply(school);
+ Assert.True(ActivitySystem.IsParentMeeting(ActivityOf(school, teacherId)));
+
+ ParentMeetingSystem.CompleteMeeting(school, teacherId);
+ Assert.Empty(school.ActiveParentMeetings);
+
+ foreach (var parentId in session.AttendeeIds)
+ {
+ var person = school.Roster!.People.First(row => row.Id == parentId);
+ if (person.IsStaff)
+ {
+ continue;
+ }
+
+ var node = NodeOf(school, parentId);
+ var headingHome = IsHeadingHome(school, parentId);
+ Assert.True(
+ node is null || headingHome,
+ $"Parent {parentId} should leave campus, node={node}, headingHome={headingHome}");
+ }
+ }
+ }
+
+ [Fact]
+ public void MeetingDoesNotStartBeforeLastLessonOfClass()
+ {
+ var (school, schoolClass, teacherId) = ClassWithParents(assignTeacher: true, advancePastLastLesson: false);
+ using (school)
+ {
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ // Still during period 1 — last lesson has not ended.
+ Assert.False(ParentMeetings.IsAfterLastLesson(
+ school.Catalog!,
+ school.Timetable,
+ schoolClass,
+ school.Clock.Time,
+ school.SchoolWeekDays));
+ Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
+ Assert.Empty(school.ActiveParentMeetings);
+ }
+ }
+
+ [Fact]
+ public void SameClassAndDaySeed_SameAttendees()
+ {
+ var first = AttendeesFromSeed(seed: 42);
+ var second = AttendeesFromSeed(seed: 42);
+ Assert.Equal(first, second);
+ Assert.NotEmpty(first);
+ }
+
+ [Fact]
+ public void PlayerCannotScheduleMeeting_NoProtocolOrPublicApi()
+ {
+ Assert.DoesNotContain(
+ Enum.GetNames(),
+ name => name.Contains("ParentMeeting", StringComparison.OrdinalIgnoreCase)
+ || name.Contains("Meeting", StringComparison.OrdinalIgnoreCase)
+ && name.Contains("Parent", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(
+ typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
+ method => method.Name.Contains("Meeting", StringComparison.OrdinalIgnoreCase)
+ && (method.Name.StartsWith("Set", StringComparison.Ordinal)
+ || method.Name.StartsWith("Start", StringComparison.Ordinal)
+ || method.Name.StartsWith("Schedule", StringComparison.Ordinal)
+ || method.Name.StartsWith("Enqueue", StringComparison.Ordinal)));
+ }
+
+ [Fact]
+ public void SkipEmpty_DoesNotLeaveParentsInHomeroom()
+ {
+ var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true);
+ using (school)
+ {
+ Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
+ var session = Assert.Single(school.ActiveParentMeetings);
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ foreach (var parentId in session.AttendeeIds)
+ {
+ PlaceAt(school, parentId, schoolClass.RoomId);
+ }
+
+ // Jump past the work window so skip is allowed, with campus cleared of pupils/staff
+ // except we force parents off via ClearAll path used by skip.
+ ParentMeetingSystem.ClearAll(school);
+ Assert.Empty(school.ActiveParentMeetings);
+ foreach (var parentId in session.AttendeeIds)
+ {
+ var person = school.Roster!.People.First(row => row.Id == parentId);
+ if (person.IsStaff || person.IsStudent)
+ {
+ continue;
+ }
+
+ Assert.Null(NodeOf(school, parentId));
+ }
+ }
+ }
+
+ [Fact]
+ public void StartingMeeting_RaisesParentMeetingWorldEvent()
+ {
+ var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true);
+ using (school)
+ {
+ school.DrainWorldEvents();
+ Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
+ var fact = Assert.Single(
+ school.DrainWorldEvents(),
+ row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
+ Assert.Equal(schoolClass.ClassTeacherId, fact.PersonKey);
+ }
+ }
+
+ private static IReadOnlyList AttendeesFromSeed(int seed)
+ {
+ var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true, seed: seed);
+ using (school)
+ {
+ Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 0.5f));
+ return Assert.Single(school.ActiveParentMeetings).AttendeeIds.ToArray();
+ }
+ }
+
+ private static (School School, SchoolClass Class, string TeacherId) ClassWithParentsAfterLastLesson(
+ bool assignTeacher,
+ int seed = 42) =>
+ ClassWithParents(assignTeacher, advancePastLastLesson: true, seed);
+
+ private static (School School, SchoolClass Class, string TeacherId) ClassWithParents(
+ bool assignTeacher,
+ bool advancePastLastLesson,
+ int seed = 42)
+ {
+ var (catalog, map) = Vanilla();
+ var start = TuesdayMorning;
+ var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start);
+ var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start);
+ var schoolClass = roster.Classes.First(row => row.RoomId == "classroom-101");
+ var school = School.Create(seed, "ParentMeeting", start, catalog, map);
+ school.InstallPeople(roster, seed, "Russia", pool);
+ school.SetTimetable(new HSchool.Schedule.Timetable(
+ [
+ new HSchool.Schedule.LessonPlacement(
+ schoolClass.Id,
+ "Mathematics",
+ "t1",
+ schoolClass.RoomId,
+ Day: 1,
+ Period: 1),
+ ],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+
+ var teacherId = "";
+ if (assignTeacher)
+ {
+ teacherId = HireTeacher(school);
+ var assigned = ClassTeachers.Assign(school.Roster!, schoolClass.Id, teacherId);
+ Assert.Equal(ClassTeacherError.None, assigned.Error);
+ school.ApplyRosterData(assigned.Roster);
+ schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id);
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ }
+
+ if (advancePastLastLesson)
+ {
+ var end = ParentMeetings.LastLessonEnd(catalog, school.Timetable, schoolClass, school.Clock.Time);
+ Assert.NotNull(end);
+ AdvanceTo(school, end.Value.AddMinutes(1));
+ if (assignTeacher)
+ {
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ }
+ }
+ else if (assignTeacher)
+ {
+ // Mid-lesson: period 1 is still running.
+ var mid = SchoolDay.PeriodStart(catalog.DayFrame!, 1);
+ var midTime = TuesdayMorning.Date + mid.ToTimeSpan() + TimeSpan.FromMinutes(10);
+ AdvanceTo(school, DateTime.SpecifyKind(midTime, DateTimeKind.Utc));
+ PlaceAt(school, teacherId, schoolClass.RoomId);
+ }
+
+ schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id);
+ return (school, schoolClass, teacherId);
+ }
+
+ private static string HireTeacher(School school)
+ {
+ const float cap = 100_000f;
+ var pool = school.Applicants!;
+ var hired = Staffing.Hire(
+ school.Catalog!,
+ school.Map!,
+ school.Roster!,
+ pool,
+ pool.Applicants[0].Person.Id,
+ Staffing.TeacherPosition,
+ cap);
+ Assert.Equal(StaffingError.None, hired.Error);
+ school.ApplyStaffing(hired.Roster, hired.Pool);
+ return school.Roster!.People.First(person =>
+ person.IsStaff && Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal)).Id;
+ }
+
+ private static void AdvanceTo(School school, DateTime until)
+ {
+ while (school.Clock.Time < until)
+ {
+ school.Tick(0.2d, 5d);
+ }
+ }
+
+ private static void PlaceAt(School school, string personId, string? nodeId)
+ {
+ TalkCircleSystem.Interrupt(school, 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 = nodeId is null ? Presence.OffCampus : new Presence(nodeId, 0f, nodeId, false, []);
+ activity = PersonActivity.Idle;
+ intent = Intent.None;
+ }
+ });
+ }
+
+ private static string? NodeOf(School school, string personId)
+ {
+ string? found = null;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ found = presence.NodeId;
+ }
+ });
+ return found;
+ }
+
+ private static bool IsHeadingHome(School school, string personId)
+ {
+ var heading = false;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ heading = presence.HeadingHome;
+ }
+ });
+ return heading;
+ }
+
+ private static string? ActivityOf(School school, string personId)
+ {
+ string? found = null;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal) && activity.IsActive)
+ {
+ found = activity.ActionId;
+ }
+ });
+ return found;
+ }
+
+ 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;
+ }
+
+ documents.Add(new ContentDocument(
+ CatalogLoader.CorePackId,
+ Path.GetRelativePath(root, path).Replace('\\', '/'),
+ File.ReadAllText(path)));
+ }
+
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ return (catalog, map);
+ }
+}