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.
This commit is contained in:
Leonid Pershin
2026-08-21 13:33:40 +03:00
parent a57e581f83
commit 3288287da9
22 changed files with 1138 additions and 18 deletions
+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))