Merge branch 'phase/72-parent-meetings'

This commit is contained in:
Leonid Pershin
2026-08-21 13:35:25 +03:00
23 changed files with 1139 additions and 19 deletions
+15 -15
View File
@@ -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 ночи не оставляет родителей вечно в кабинете
## Критерий готовности
+1 -1
View File
@@ -29,6 +29,6 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [72. Родительские собрания](72-parent-meetings.md) | 🔄 | ИИ классного, родители на карте |
| [72. Родительские собрания](72-parent-meetings.md) | | ИИ классного, родители на карте |
72 стоит на 68 и 71.
+129
View File
@@ -0,0 +1,129 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Ai;
/// <summary>
/// Pure helpers for AI class-teacher parent meetings. Session state lives on the school worker.
/// </summary>
public static class ParentMeetings
{
public const string MeetingAction = "ParentMeeting";
/// <summary>Presence / card label while walking to the homeroom. Not a planner pick.</summary>
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);
/// <summary>
/// Unique parents of the class pupils, ordinal order. Teacher-parents stay in the list — one
/// roster id, no second instance.
/// </summary>
public static IReadOnlyList<string> ClassParentIds(Roster roster, SchoolClass schoolClass)
{
ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(schoolClass);
var pupilSet = schoolClass.PupilIds.ToHashSet(StringComparer.Ordinal);
var parents = new HashSet<string>(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<string> 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<string>();
foreach (var parentId in parents)
{
var seed = Seed.Mix(schoolSeed, parentId, dayNumber, Seed.MeetingAttendSalt);
if (TalkCircles.Roll01(seed) < fraction)
{
chosen.Add(parentId);
}
}
return chosen;
}
}
+2
View File
@@ -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…',
@@ -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;
+1
View File
@@ -15,6 +15,7 @@ internal static class EventDefValidator
EventTriggers.LessonStart,
EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
EventTriggers.ParentMeeting,
};
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
+1
View File
@@ -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
+18
View File
@@ -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 01.");
}
if (behavior.ParentMeetingAttendanceFraction is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' parentMeetingAttendanceFraction must be 01.");
}
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.");
+19
View File
@@ -595,6 +595,25 @@ public sealed class BehaviorDef : Def
/// <summary>Opinion of the Principal after a hearing. Negative — the visit is not praise.</summary>
public int DirectorHearingOpinionShift { get; init; } = -12;
/// <summary>
/// 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.
/// </summary>
public float ParentMeetingChance { get; init; } = 0.08f;
/// <summary>
/// Independent chance each class parent is invited when a meeting starts. Not 100% attendance.
/// </summary>
public float ParentMeetingAttendanceFraction { get; init; } = 0.4f;
/// <summary>Game minutes the class teacher keeps the homeroom for the meeting.</summary>
public float ParentMeetingMinutes { get; init; } = 20f;
/// <summary>
/// Opinion shift both ways between each attending parent and the class teacher. Small and positive.
/// </summary>
public int ParentMeetingOpinionShift { get; init; } = 3;
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
[
"quarrel",
+2
View File
@@ -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;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
+2 -1
View File
@@ -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))
{
@@ -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",
@@ -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 25 from lesson quality (slice 13 phase 73). One mark per slot.
"lessonMarkMax": 40,
// Inclusive quality floors for 5, 4, 3 (descending). Below the last → 2.
@@ -31,4 +31,12 @@
"trigger": "directorSummon",
"action": "none",
},
{
"defName": "ParentMeeting",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "parentMeeting",
"action": "none",
},
]
@@ -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",
}
@@ -232,7 +232,9 @@
"LessonStarted": "Начало урока",
"GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
"ParentMeeting": "Идёт родительское собрание",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"GoingToParentMeeting": "идёт на собрание",
"core": "Базовая игра",
}
+9
View File
@@ -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;
@@ -0,0 +1,451 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// Rare AI parent meetings after a class's last lesson. Only the school worker mutates
/// <see cref="School.ActiveParentMeetings"/>.
/// </summary>
internal static class ParentMeetingSystem
{
private static readonly QueryDescription PresenceQuery =
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
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);
}
}
/// <summary>
/// Start a meeting for one class when rarity and attendance pass. Used by tests with chance 1.
/// </summary>
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<string> 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;
}
}
/// <summary>One active parent meeting for a class.</summary>
internal sealed class ParentMeetingSession(
string classId,
string teacherId,
string homeroomId,
IReadOnlyList<string> attendeeIds)
{
public string ClassId { get; } = classId;
public string TeacherId { get; } = teacherId;
public string HomeroomId { get; } = homeroomId;
public IReadOnlyList<string> AttendeeIds { get; } = attendeeIds;
public bool InProgress { get; set; }
}
+26 -2
View File
@@ -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)
{
+34
View File
@@ -153,6 +153,14 @@ public sealed class School : IDisposable
internal int NextSummonOrder { get; set; }
/// <summary>
/// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
/// </summary>
internal List<ParentMeetingSession> ActiveParentMeetings { get; } = [];
/// <summary>Class ids that already rolled a meeting chance today (pass or fail).</summary>
internal HashSet<string> ParentMeetingRolledToday { get; } = new(StringComparer.Ordinal);
/// <summary>
/// A finished or interrupted circle wrote opinions onto the roster. <see cref="Tick"/>
/// returns this so the worker persists <c>people.json</c> — 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));
/// <summary>
/// Card label while walking to / sitting in a parent meeting without a live activity yet.
/// </summary>
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);
@@ -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))
@@ -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<ContentDocument>();
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<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"behavior",
"rules",
"""{ "defName": "Behavior", "parentMeetingChance": 1.5 }"""),
]));
Assert.Contains("parentMeetingChance", error.Message, StringComparison.Ordinal);
}
}
@@ -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<MessageType>(),
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<string> 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<PersonIdentity, Presence, PersonActivity, Intent>();
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<PersonIdentity, Presence>();
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<PersonIdentity, Presence>();
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<PersonIdentity, PersonActivity>();
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<ContentDocument>();
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);
}
}