Auto-summon pupils to the principal after fight breaks, with a corridor FIFO queue.

This commit is contained in:
Leonid Pershin
2026-08-21 11:49:06 +03:00
parent 8f1a82f42d
commit e40f130248
17 changed files with 839 additions and 18 deletions
+105
View File
@@ -0,0 +1,105 @@
using HSchool.Content;
using HSchool.People;
namespace HSchool.Ai;
/// <summary>
/// Pure map / roster helpers for auto director summons. Queue state lives on the school worker.
/// </summary>
public static class DirectorSummons
{
public const string HearingAction = "PrincipalHearing";
public const string OfficeRoomDef = "PrincipalsOffice";
public const string CorridorRoomDef = "Corridor";
public static string? OfficeNodeId(MapLayout map)
{
ArgumentNullException.ThrowIfNull(map);
return map.Rooms
.Where(room => room.Def.Equals(OfficeRoomDef, StringComparison.Ordinal))
.Select(room => room.Id)
.OrderBy(id => id, StringComparer.Ordinal)
.FirstOrDefault();
}
/// <summary>
/// Corridor (or other neighbour) where the queue waits — never inside the office.
/// Prefer a <see cref="CorridorRoomDef"/> link; tie-break by ordinal id.
/// </summary>
public static string? WaitNodeId(MapLayout map, string? officeNodeId = null)
{
ArgumentNullException.ThrowIfNull(map);
officeNodeId ??= OfficeNodeId(map);
if (officeNodeId is null)
{
return null;
}
string? bestCorridor = null;
string? bestAny = null;
foreach (var link in map.Links)
{
var other = OtherEnd(link, officeNodeId);
if (other is null)
{
continue;
}
bestAny = PickOrdinal(bestAny, other);
if (map.NodeDef(other) is { } def
&& def.Equals(CorridorRoomDef, StringComparison.Ordinal))
{
bestCorridor = PickOrdinal(bestCorridor, other);
}
}
return bestCorridor ?? bestAny;
}
public static bool HasHiredPrincipal(Roster roster) =>
roster.People.Any(person =>
person.IsStaff
&& Staffing.PrincipalPosition.Equals(person.Position, StringComparison.Ordinal));
public static string? PrincipalId(Roster roster) =>
roster.People
.Where(person =>
person.IsStaff
&& Staffing.PrincipalPosition.Equals(person.Position, StringComparison.Ordinal))
.Select(person => person.Id)
.OrderBy(id => id, StringComparer.Ordinal)
.FirstOrDefault();
public static bool CanStart(Roster roster, MapLayout map) =>
HasHiredPrincipal(roster) && OfficeNodeId(map) is not null && WaitNodeId(map) is not null;
public static bool RollSummon(float chance, int seed) =>
chance >= 1f || (chance > 0f && TalkCircles.Roll01(seed) < chance);
private static string? OtherEnd(MapLink link, string nodeId)
{
if (link.A.Equals(nodeId, StringComparison.Ordinal))
{
return link.B;
}
if (link.B.Equals(nodeId, StringComparison.Ordinal))
{
return link.A;
}
return null;
}
private static string PickOrdinal(string? current, string candidate)
{
if (current is null)
{
return candidate;
}
return string.CompareOrdinal(candidate, current) < 0 ? candidate : current;
}
}
+18
View File
@@ -665,6 +665,24 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryKinds cannot contain empty ids.");
}
}
if (behavior.DirectorSummonFightBreakChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorSummonFightBreakChance must be 01.");
}
if (behavior.DirectorSummonReprimandChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorSummonReprimandChance must be 01.");
}
if (behavior.DirectorHearingMinutes <= 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
+18
View File
@@ -577,6 +577,24 @@ public sealed class BehaviorDef : Def
/// </summary>
public IReadOnlyList<string> OffenseMemoryKinds { get; init; } = DefaultOffenseMemoryKinds;
/// <summary>
/// Chance a pupil is auto-summoned after staff breaks a fight. 1 = always when a Principal
/// is hired and the office exists. Missing keeps always-on so vanilla matches the design.
/// </summary>
public float DirectorSummonFightBreakChance { get; init; } = 1f;
/// <summary>
/// Chance after a teacher discipline / caught-whisper reprimand. Below fight-break so not
/// every scolding empties the corridor.
/// </summary>
public float DirectorSummonReprimandChance { get; init; } = 0.35f;
/// <summary>Game minutes the pupil spends in the office hearing. Drives the ActionDef copy.</summary>
public float DirectorHearingMinutes { get; init; } = 5f;
/// <summary>Opinion of the Principal after a hearing. Negative — the visit is not praise.</summary>
public int DirectorHearingOpinionShift { get; init; } = -12;
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
[
"quarrel",
+1
View File
@@ -21,6 +21,7 @@ public static class Seed
public const int OrientationSalt = 13;
public const int ConflictSalt = 14;
public const int HomeSalt = 15;
public const int SummonSalt = 16;
/// <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
View File
@@ -43,6 +43,8 @@ public static class Staffing
{
public const string TeacherPosition = "Teacher";
public const string PrincipalPosition = "Principal";
public static StaffingOutcome UnknownSchool() =>
Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0);
@@ -131,6 +131,15 @@
"roles": ["student"],
"weight": 0,
},
{
// Short office hearing after an auto summons (slice 12 phase 70). Weight 0 — planner never
// picks it; DirectorSummonSystem starts it when the pupil is admitted and idle in the office.
"defName": "PrincipalHearing",
"room": "PrincipalsOffice",
"minutes": 5,
"roles": ["student"],
"weight": 0,
},
{
"defName": "ChangeClothesMale",
"room": "MaleChangingRoom",
@@ -99,4 +99,9 @@
// Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score.
"offenseMemoryMax": 5,
"offenseMemoryKinds": ["quarrel", "fight", "reprimand"],
// Auto director summons (slice 12 phase 70). Fight-break always; reprimand rarer.
"directorSummonFightBreakChance": 1,
"directorSummonReprimandChance": 0.35,
"directorHearingMinutes": 5,
"directorHearingOpinionShift": -12,
}
@@ -211,6 +211,7 @@
"OffenseQuarrel": "quarrel",
"OffenseFight": "fight",
"OffenseReprimand": "reprimand",
"PrincipalHearing": "Principal's hearing",
"TopicStudy": "schoolwork",
"TopicGames": "games",
"TopicFood": "food",
@@ -211,6 +211,7 @@
"OffenseQuarrel": "ссора",
"OffenseFight": "драка",
"OffenseReprimand": "выговор",
"PrincipalHearing": "Приём у директора",
"TopicStudy": "учёбе",
"TopicGames": "играх",
"TopicFood": "еде",
+9
View File
@@ -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;
}
+26 -3
View File
@@ -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);
}
+12
View File
@@ -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);
}
}