Auto-summon pupils to the principal after fight breaks, with a corridor FIFO queue.
This commit is contained in:
@@ -153,6 +153,11 @@ internal static class ActivitySystem
|
||||
FinishChangeClothes(school, identity.Id);
|
||||
}
|
||||
|
||||
if (IsPrincipalHearing(action.DefName))
|
||||
{
|
||||
DirectorSummonSystem.CompleteHearing(school, identity.Id);
|
||||
}
|
||||
|
||||
activity = PersonActivity.Idle;
|
||||
completed.Add(identity.Id);
|
||||
});
|
||||
@@ -181,6 +186,10 @@ internal static class ActivitySystem
|
||||
&& (actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal)
|
||||
|| actionId.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal));
|
||||
|
||||
internal static bool IsPrincipalHearing(string? actionId) =>
|
||||
actionId is not null
|
||||
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
|
||||
|
||||
private static int Occupied(School school, string nodeId, string thing)
|
||||
{
|
||||
var occupied = 0;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Auto director summons: enqueue from misconduct, FIFO corridor wait, one hearing in the office.
|
||||
/// Only the school worker thread mutates <see cref="School.DirectorSummons"/>.
|
||||
/// </summary>
|
||||
internal static class DirectorSummonSystem
|
||||
{
|
||||
private static readonly QueryDescription PresenceQuery =
|
||||
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||
|
||||
public static bool IsSummoned(School school, string personId) =>
|
||||
school.DirectorSummons.Any(row => row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||
|
||||
public static bool TryDutyRoom(School school, string personId, out string roomId)
|
||||
{
|
||||
roomId = null!;
|
||||
var ticket = school.DirectorSummons.FirstOrDefault(row =>
|
||||
row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||
if (ticket is null || school.Map is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var office = DirectorSummons.OfficeNodeId(school.Map);
|
||||
var wait = DirectorSummons.WaitNodeId(school.Map, office);
|
||||
if (office is null || wait is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
roomId = ticket.Admitted ? office : wait;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a staff-broken fight. Pupils only; order is ordinal so the same fight always queues
|
||||
/// the same way.
|
||||
/// </summary>
|
||||
public static void TryEnqueueAfterFightBreak(School school, IReadOnlyList<Person> people)
|
||||
{
|
||||
var rules = school.Catalog?.BehaviorRules;
|
||||
var chance = rules?.DirectorSummonFightBreakChance ?? 1f;
|
||||
foreach (var person in people.OrderBy(row => row.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!person.IsStudent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryEnqueue(school, person.Id, chance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Teacher discipline / caught whisper. Pupils only.</summary>
|
||||
public static void TryEnqueueAfterReprimand(School school, IEnumerable<string> pupilIds)
|
||||
{
|
||||
var rules = school.Catalog?.BehaviorRules;
|
||||
var chance = rules?.DirectorSummonReprimandChance ?? 0.35f;
|
||||
foreach (var id in pupilIds.OrderBy(row => row, StringComparer.Ordinal))
|
||||
{
|
||||
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(id, StringComparison.Ordinal));
|
||||
if (person is null || !person.IsStudent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryEnqueue(school, id, chance);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryEnqueue(School school, string personId, float chance)
|
||||
{
|
||||
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DirectorSummons.CanStart(school.Roster, school.Map))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsSummoned(school, personId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var seed = Seed.Mix(
|
||||
school.PeopleSeed,
|
||||
personId,
|
||||
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
|
||||
Seed.SummonSalt);
|
||||
if (!DirectorSummons.RollSummon(chance, seed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false));
|
||||
PresenceSystem.Enqueue(school, personId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ClearAll(School school)
|
||||
{
|
||||
if (school.DirectorSummons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = school.DirectorSummons.Select(row => row.PersonId).ToArray();
|
||||
school.DirectorSummons.Clear();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
PresenceSystem.Enqueue(school, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop hung tickets when the Principal is gone or the office vanished, then admit the head
|
||||
/// of the FIFO and start a hearing for whoever is idle in the office.
|
||||
/// </summary>
|
||||
public static void Apply(School school)
|
||||
{
|
||||
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DirectorSummons.CanStart(school.Roster, school.Map))
|
||||
{
|
||||
ClearAll(school);
|
||||
return;
|
||||
}
|
||||
|
||||
if (school.DirectorSummons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AdmitHead(school);
|
||||
TryStartHearing(school);
|
||||
}
|
||||
|
||||
public static void CompleteHearing(School school, string personId)
|
||||
{
|
||||
var ticket = school.DirectorSummons.FirstOrDefault(row =>
|
||||
row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||
if (ticket is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
school.DirectorSummons.Remove(ticket);
|
||||
ApplyHearingEffects(school, personId);
|
||||
PresenceSystem.Enqueue(school, personId);
|
||||
AdmitHead(school);
|
||||
foreach (var waiting in school.DirectorSummons)
|
||||
{
|
||||
PresenceSystem.Enqueue(school, waiting.PersonId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AdmitHead(School school)
|
||||
{
|
||||
if (school.DirectorSummons.Any(row => row.Admitted))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var head = school.DirectorSummons.OrderBy(row => row.Order).FirstOrDefault();
|
||||
if (head is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
head.Admitted = true;
|
||||
PresenceSystem.Enqueue(school, head.PersonId);
|
||||
}
|
||||
|
||||
private static void TryStartHearing(School school)
|
||||
{
|
||||
var admitted = school.DirectorSummons.FirstOrDefault(row => row.Admitted);
|
||||
if (admitted is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var office = DirectorSummons.OfficeNodeId(school.Map!);
|
||||
if (office is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? nodeId = null;
|
||||
var walking = false;
|
||||
var busy = false;
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
|
||||
{
|
||||
if (!identity.Id.Equals(admitted.PersonId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nodeId = presence.NodeId;
|
||||
walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|
||||
|| (presence.DestinationId is not null
|
||||
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
|
||||
busy = activity.IsActive;
|
||||
});
|
||||
|
||||
if (busy
|
||||
|| walking
|
||||
|| nodeId is null
|
||||
|| !nodeId.Equals(office, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!school.Catalog!.Actions.TryGetValue(DirectorSummons.HearingAction, out var action)
|
||||
|| action.Abstract)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var minutes = school.Catalog.BehaviorRules?.DirectorHearingMinutes ?? 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(admitted.PersonId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity = new PersonActivity(DirectorSummons.HearingAction, null, minutes);
|
||||
intent = Intent.None;
|
||||
started = true;
|
||||
});
|
||||
}
|
||||
|
||||
private static void ApplyHearingEffects(School school, string personId)
|
||||
{
|
||||
var person = school.Roster!.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||
var principalId = DirectorSummons.PrincipalId(school.Roster);
|
||||
var rules = school.Catalog!.BehaviorRules;
|
||||
if (person is null || principalId is null || rules is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = OpinionStore.Get(person, principalId) ?? 0;
|
||||
OpinionStore.Set(person, principalId, current + rules.DirectorHearingOpinionShift);
|
||||
OffenseMemory.Record(
|
||||
person,
|
||||
OffenseKinds.Reprimand,
|
||||
school.Clock.Time,
|
||||
principalId,
|
||||
rules);
|
||||
school.RosterTalkDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One auto summons ticket. <see cref="Order"/> is FIFO; lower goes first.</summary>
|
||||
internal sealed class DirectorSummonTicket(string personId, int order, bool admitted)
|
||||
{
|
||||
public string PersonId { get; } = personId;
|
||||
|
||||
public int Order { get; } = order;
|
||||
|
||||
public bool Admitted { get; set; } = admitted;
|
||||
}
|
||||
@@ -331,6 +331,11 @@ 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))
|
||||
{
|
||||
bound = true;
|
||||
}
|
||||
|
||||
if (activity.IsActive && TalkActions.IsTalk(activity.ActionId))
|
||||
{
|
||||
if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId))
|
||||
@@ -342,6 +347,11 @@ internal static class PresenceSystem
|
||||
activity = PersonActivity.Idle;
|
||||
}
|
||||
|
||||
if (activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
intent = Intent.None;
|
||||
@@ -354,6 +364,11 @@ internal static class PresenceSystem
|
||||
school.Catalog!,
|
||||
now,
|
||||
school.SchoolWeekDays);
|
||||
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonArrive))
|
||||
{
|
||||
dest = summonArrive;
|
||||
}
|
||||
|
||||
presence = dest is null
|
||||
? Presence.OffCampus
|
||||
: PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
|
||||
@@ -364,7 +379,7 @@ internal static class PresenceSystem
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.WalkHomeAt is { } leave && now >= leave)
|
||||
if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id))
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
@@ -379,6 +394,12 @@ internal static class PresenceSystem
|
||||
school.Catalog!,
|
||||
now,
|
||||
school.SchoolWeekDays);
|
||||
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonRoom))
|
||||
{
|
||||
duty = summonRoom;
|
||||
bound = true;
|
||||
}
|
||||
|
||||
if (duty is null)
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
@@ -416,18 +437,20 @@ internal static class PresenceSystem
|
||||
|
||||
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
|
||||
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
|
||||
var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
|
||||
|
||||
if (decision.WalkTo is not null
|
||||
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
||||
&& activity.IsActive
|
||||
&& !changing
|
||||
&& !inTalk)
|
||||
&& !inTalk
|
||||
&& !inHearing)
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
}
|
||||
|
||||
intent = decision.Intent;
|
||||
if (decision.WalkTo is not null && !changing && !inTalk)
|
||||
if (decision.WalkTo is not null && !changing && !inTalk && !inHearing)
|
||||
{
|
||||
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,13 @@ public sealed class School : IDisposable
|
||||
|
||||
internal Dictionary<string, ApologyDebt> ApologyDebts { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Auto director summons FIFO. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
internal List<DirectorSummonTicket> DirectorSummons { get; } = [];
|
||||
|
||||
internal int NextSummonOrder { get; set; }
|
||||
|
||||
/// <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.
|
||||
@@ -290,6 +297,7 @@ public sealed class School : IDisposable
|
||||
var before = Clock.Time;
|
||||
Clock.JumpTo(next.Value);
|
||||
TalkCircleSystem.AbandonAll(this);
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
ResetDayLog();
|
||||
var peopleChanged = TryYearlyIntake(before, next.Value);
|
||||
peopleChanged |= TryApplicantRefresh();
|
||||
@@ -362,6 +370,8 @@ public sealed class School : IDisposable
|
||||
if (PersonDayLog.CrossedDayStart(before, Clock.Time))
|
||||
{
|
||||
ResetDayLog();
|
||||
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
}
|
||||
|
||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||
@@ -428,6 +438,8 @@ public sealed class School : IDisposable
|
||||
PresenceSystem.Enqueue(this, id);
|
||||
}
|
||||
|
||||
DirectorSummonSystem.Apply(this);
|
||||
|
||||
PersonDayLog.Sync(this);
|
||||
|
||||
if (Catalog is not null)
|
||||
|
||||
@@ -56,6 +56,7 @@ internal static class TalkCircleSystem
|
||||
}
|
||||
|
||||
school.ApologyDebts.Clear();
|
||||
DirectorSummonSystem.ClearAll(school);
|
||||
school.World.Query(in People, (ref PersonActivity activity) =>
|
||||
{
|
||||
if (TalkActions.IsConflict(activity.ActionId))
|
||||
@@ -546,6 +547,14 @@ internal static class TalkCircleSystem
|
||||
|
||||
school.TalkCirclesById.Remove(circle.Id);
|
||||
school.RosterTalkDirty = true;
|
||||
|
||||
if (TalkActions.IsTeacherTalk(circle.ActionId)
|
||||
&& TeacherTopics.Discipline.Equals(circle.TopicId, StringComparison.Ordinal))
|
||||
{
|
||||
DirectorSummonSystem.TryEnqueueAfterReprimand(
|
||||
school,
|
||||
people.Where(person => person.IsStudent).Select(person => person.Id));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCatchWhisper(School school, ActiveTalkCircle circle)
|
||||
@@ -1008,6 +1017,8 @@ internal static class TalkCircleSystem
|
||||
PersonLogTypes.Reprimanded,
|
||||
circle.ActionId));
|
||||
}
|
||||
|
||||
DirectorSummonSystem.TryEnqueueAfterFightBreak(school, people);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user