From 3288287da9bc8dfc19b79fc04ca16b0b28a3a8dd Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:33:40 +0300 Subject: [PATCH 1/2] Add AI parent meetings after a class last lesson. Class teachers rarely keep the homeroom; invited parents walk onto the map, opinions nudge, then parents leave OffCampus. No player schedule API. --- docs/phases/12-office/72-parent-meetings.md | 30 +- src/HSchool.Ai/ParentMeetings.cs | 129 +++++ src/HSchool.Client/src/i18n/strings.ts | 2 + src/HSchool.Client/src/ui/noticeToasts.ts | 1 + src/HSchool.Content/EventDefValidator.cs | 1 + src/HSchool.Content/EventDefs.cs | 1 + src/HSchool.Content/PeopleDefValidator.cs | 18 + src/HSchool.Content/PeopleDefs.cs | 19 + src/HSchool.People/Seed.cs | 2 + src/HSchool.Server/Game/PersonCardReader.cs | 3 +- .../mods/core/defs/actions/living.jsonc | 16 + .../mods/core/defs/behavior/rules.jsonc | 5 + .../mods/core/defs/events/notices.jsonc | 8 + .../mods/core/localizations/en.jsonc | 2 + .../mods/core/localizations/ru.jsonc | 2 + src/HSchool.Simulation/ActivitySystem.cs | 9 + src/HSchool.Simulation/ParentMeetingSystem.cs | 451 ++++++++++++++++++ src/HSchool.Simulation/PresenceSystem.cs | 28 +- src/HSchool.Simulation/School.cs | 34 ++ src/HSchool.Simulation/TalkCircleSystem.cs | 1 + .../ParentMeetingBehaviorTests.cs | 54 +++ .../ParentMeetingTests.cs | 340 +++++++++++++ 22 files changed, 1138 insertions(+), 18 deletions(-) create mode 100644 src/HSchool.Ai/ParentMeetings.cs create mode 100644 src/HSchool.Simulation/ParentMeetingSystem.cs create mode 100644 tests/HSchool.Content.Tests/ParentMeetingBehaviorTests.cs create mode 100644 tests/HSchool.Simulation.Tests/ParentMeetingTests.cs 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); + } +} From 83af9ef9761e5462fad65a4eb33558ef584b8c83 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:34:35 +0300 Subject: [PATCH 2/2] Mark phase 72 parent-meetings done. --- docs/phases/12-office/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/12-office/README.md b/docs/phases/12-office/README.md index 6196bac..e0f0129 100644 --- a/docs/phases/12-office/README.md +++ b/docs/phases/12-office/README.md @@ -29,6 +29,6 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [72. Родительские собрания](72-parent-meetings.md) | 🔄 | ИИ классного, родители на карте | +| [72. Родительские собрания](72-parent-meetings.md) | ✅ | ИИ классного, родители на карте | 72 стоит на 68 и 71.