diff --git a/docs/phases/12-office/70-director-summons.md b/docs/phases/12-office/70-director-summons.md
index aca9274..00aa5ea 100644
--- a/docs/phases/12-office/70-director-summons.md
+++ b/docs/phases/12-office/70-director-summons.md
@@ -12,27 +12,27 @@
## Задачи
-- [ ] Авто-триггер вызова из фактов (обрыв драки / выговор и пороги в `BehaviorDef`); кнопки
+- [x] Авто-триггер вызова из фактов (обрыв драки / выговор и пороги в `BehaviorDef`); кнопки
игрока нет
-- [ ] Временное состояние «вызван» — сильная обязанность дойти до `PrincipalsOffice`, пройти
+- [x] Временное состояние «вызван» — сильная обязанность дойти до `PrincipalsOffice`, пройти
приём, сняться
-- [ ] Нет директора в штате или нет узла кабинета — вызов не стартует / гаснет по правилу в данных
-- [ ] Кабинет занят приёмом → ждут в коридорном узле у ребра к кабинету, не внутри
-- [ ] В кабинете один на приём; очередь FIFO с детерминированным порядком
-- [ ] Приём — короткое действие; мнение о директоре падает; при необходимости допись в список
+- [x] Нет директора в штате или нет узла кабинета — вызов не стартует / гаснет по правилу в данных
+- [x] Кабинет занят приёмом → ждут в коридорном узле у ребра к кабинету, не внутри
+- [x] В кабинете один на приём; очередь FIFO с детерминированным порядком
+- [x] Приём — короткое действие; мнение о директоре падает; при необходимости допись в список
проступков (69)
-- [ ] Обычная ходьба и `travelMinutes`; телепорта нет
-- [ ] Skip / утро не оставляют вечный «вызван» без разрешения (явное правило в коде фазы)
-- [ ] Очередь и вызовы только на потоке школы (инвариант 3)
+- [x] Обычная ходьба и `travelMinutes`; телепорта нет
+- [x] Skip / утро не оставляют вечный «вызван» без разрешения (явное правило в коде фазы)
+- [x] Очередь и вызовы только на потоке школы (инвариант 3)
## Тесты, без которых фаза не закрыта
-- [ ] После qualifying-проступка ученик получает обязанность к кабинету (при живом директоре)
-- [ ] Двое вызванных: один в кабинете, второй в коридоре у входа, не оба внутри
-- [ ] Освобождение кабинета впускает следующего из очереди
-- [ ] Тот же сид и те же события → тот же порядок очереди
-- [ ] Без директора вызов не зависает навечно
-- [ ] Игрок не может послать вызов по HTTP/сокету (нет такого намерения)
+- [x] После qualifying-проступка ученик получает обязанность к кабинету (при живом директоре)
+- [x] Двое вызванных: один в кабинете, второй в коридоре у входа, не оба внутри
+- [x] Освобождение кабинета впускает следующего из очереди
+- [x] Тот же сид и те же события → тот же порядок очереди
+- [x] Без директора вызов не зависает навечно
+- [x] Игрок не может послать вызов по HTTP/сокету (нет такого намерения)
## Критерий готовности
diff --git a/src/HSchool.Ai/DirectorSummons.cs b/src/HSchool.Ai/DirectorSummons.cs
new file mode 100644
index 0000000..6804628
--- /dev/null
+++ b/src/HSchool.Ai/DirectorSummons.cs
@@ -0,0 +1,105 @@
+using HSchool.Content;
+using HSchool.People;
+
+namespace HSchool.Ai;
+
+///
+/// Pure map / roster helpers for auto director summons. Queue state lives on the school worker.
+///
+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();
+ }
+
+ ///
+ /// Corridor (or other neighbour) where the queue waits — never inside the office.
+ /// Prefer a link; tie-break by ordinal id.
+ ///
+ 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;
+ }
+}
diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs
index e2e1bbe..fb1fa18 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -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 0–1.");
+ }
+
+ if (behavior.DirectorSummonReprimandChance is < 0f or > 1f)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' directorSummonReprimandChance must be 0–1.");
+ }
+
+ if (behavior.DirectorHearingMinutes <= 0f)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
+ }
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 3f84a20..02a7fc6 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -577,6 +577,24 @@ public sealed class BehaviorDef : Def
///
public IReadOnlyList OffenseMemoryKinds { get; init; } = DefaultOffenseMemoryKinds;
+ ///
+ /// 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.
+ ///
+ public float DirectorSummonFightBreakChance { get; init; } = 1f;
+
+ ///
+ /// Chance after a teacher discipline / caught-whisper reprimand. Below fight-break so not
+ /// every scolding empties the corridor.
+ ///
+ public float DirectorSummonReprimandChance { get; init; } = 0.35f;
+
+ /// Game minutes the pupil spends in the office hearing. Drives the ActionDef copy.
+ public float DirectorHearingMinutes { get; init; } = 5f;
+
+ /// Opinion of the Principal after a hearing. Negative — the visit is not praise.
+ public int DirectorHearingOpinionShift { get; init; } = -12;
+
public static IReadOnlyList DefaultOffenseMemoryKinds { get; } =
[
"quarrel",
diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs
index 86f37e2..6cb7783 100644
--- a/src/HSchool.People/Seed.cs
+++ b/src/HSchool.People/Seed.cs
@@ -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;
/// 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.People/Staffing.cs b/src/HSchool.People/Staffing.cs
index e62194b..b823879 100644
--- a/src/HSchool.People/Staffing.cs
+++ b/src/HSchool.People/Staffing.cs
@@ -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);
diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
index 74eb0b0..c338ec6 100644
--- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc
+++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
@@ -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",
diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
index 8480075..1a8eeaa 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -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,
}
diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc
index 9054b26..a88d0be 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -211,6 +211,7 @@
"OffenseQuarrel": "quarrel",
"OffenseFight": "fight",
"OffenseReprimand": "reprimand",
+ "PrincipalHearing": "Principal's hearing",
"TopicStudy": "schoolwork",
"TopicGames": "games",
"TopicFood": "food",
diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc
index 7f76765..9726d87 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -211,6 +211,7 @@
"OffenseQuarrel": "ссора",
"OffenseFight": "драка",
"OffenseReprimand": "выговор",
+ "PrincipalHearing": "Приём у директора",
"TopicStudy": "учёбе",
"TopicGames": "играх",
"TopicFood": "еде",
diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs
index 9d57972..a135e13 100644
--- a/src/HSchool.Simulation/ActivitySystem.cs
+++ b/src/HSchool.Simulation/ActivitySystem.cs
@@ -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;
diff --git a/src/HSchool.Simulation/DirectorSummonSystem.cs b/src/HSchool.Simulation/DirectorSummonSystem.cs
new file mode 100644
index 0000000..2d40029
--- /dev/null
+++ b/src/HSchool.Simulation/DirectorSummonSystem.cs
@@ -0,0 +1,292 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+
+namespace HSchool.Simulation;
+
+///
+/// Auto director summons: enqueue from misconduct, FIFO corridor wait, one hearing in the office.
+/// Only the school worker thread mutates .
+///
+internal static class DirectorSummonSystem
+{
+ private static readonly QueryDescription PresenceQuery =
+ new QueryDescription().WithAll();
+
+ 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;
+ }
+
+ ///
+ /// After a staff-broken fight. Pupils only; order is ordinal so the same fight always queues
+ /// the same way.
+ ///
+ public static void TryEnqueueAfterFightBreak(School school, IReadOnlyList 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);
+ }
+ }
+
+ /// Teacher discipline / caught whisper. Pupils only.
+ public static void TryEnqueueAfterReprimand(School school, IEnumerable 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+}
+
+/// One auto summons ticket. is FIFO; lower goes first.
+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;
+}
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
index 44541cc..864328c 100644
--- a/src/HSchool.Simulation/PresenceSystem.cs
+++ b/src/HSchool.Simulation/PresenceSystem.cs
@@ -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);
}
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index b1e7d00..1993718 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -142,6 +142,13 @@ public sealed class School : IDisposable
internal Dictionary ApologyDebts { get; } = new(StringComparer.Ordinal);
+ ///
+ /// Auto director summons FIFO. Worker thread only — never HTTP. Cleared on morning / skip.
+ ///
+ internal List DirectorSummons { get; } = [];
+
+ internal int NextSummonOrder { get; set; }
+
///
/// A finished or interrupted circle wrote opinions onto the roster.
/// returns this so the worker persists people.json — 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)
diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs
index 78cc82c..7d0db8a 100644
--- a/src/HSchool.Simulation/TalkCircleSystem.cs
+++ b/src/HSchool.Simulation/TalkCircleSystem.cs
@@ -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);
}
}
diff --git a/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs b/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs
new file mode 100644
index 0000000..d819ce2
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/DirectorSummonTests.cs
@@ -0,0 +1,313 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Protocol;
+using HSchool.Simulation;
+
+namespace HSchool.Simulation.Tests;
+
+public class DirectorSummonTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void FightBrokenByStaff_WithPrincipal_SummonsPupils()
+ {
+ var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
+ using (school)
+ {
+ BreakFight(school, first, second);
+ Assert.True(DirectorSummonSystem.IsSummoned(school, first.Id));
+ Assert.True(DirectorSummonSystem.IsSummoned(school, second.Id));
+ Assert.Equal(2, school.DirectorSummons.Count);
+ }
+ }
+
+ [Fact]
+ public void TwoSummoned_OneInOffice_OtherWaitsInCorridor()
+ {
+ var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
+ using (school)
+ {
+ Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
+ Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
+ DirectorSummonSystem.Apply(school);
+
+ var ordered = school.DirectorSummons.OrderBy(row => row.Order).ToArray();
+ Assert.Equal(2, ordered.Length);
+ Assert.True(ordered[0].Admitted);
+ Assert.False(ordered[1].Admitted);
+
+ Assert.True(DirectorSummonSystem.TryDutyRoom(school, ordered[0].PersonId, out var admittedDuty));
+ Assert.Equal("principals-office", admittedDuty);
+ Assert.True(DirectorSummonSystem.TryDutyRoom(school, ordered[1].PersonId, out var waitingDuty));
+ Assert.Equal("corridor-1", waitingDuty);
+
+ PlaceAt(school, ordered[0].PersonId, "principals-office");
+ PlaceAt(school, ordered[1].PersonId, "corridor-1");
+ Assert.Equal("principals-office", NodeOf(school, ordered[0].PersonId));
+ Assert.Equal("corridor-1", NodeOf(school, ordered[1].PersonId));
+ Assert.False(
+ "principals-office".Equals(NodeOf(school, ordered[1].PersonId), StringComparison.Ordinal));
+ }
+ }
+
+ [Fact]
+ public void FreeingOffice_AdmitsNextFromQueue()
+ {
+ var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak();
+ using (school)
+ {
+ Assert.True(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
+ Assert.True(DirectorSummonSystem.TryEnqueue(school, second.Id, chance: 1f));
+ DirectorSummonSystem.Apply(school);
+
+ var head = school.DirectorSummons.OrderBy(row => row.Order).First();
+ var next = school.DirectorSummons.OrderBy(row => row.Order).Skip(1).First();
+ PlaceAt(school, head.PersonId, "principals-office");
+ DirectorSummonSystem.Apply(school);
+ Assert.True(ActivitySystem.IsPrincipalHearing(ActivityOf(school, head.PersonId)));
+
+ DirectorSummonSystem.CompleteHearing(school, head.PersonId);
+ DirectorSummonSystem.Apply(school);
+
+ Assert.False(DirectorSummonSystem.IsSummoned(school, head.PersonId));
+ Assert.True(DirectorSummonSystem.IsSummoned(school, next.PersonId));
+ var remaining = Assert.Single(school.DirectorSummons);
+ Assert.True(remaining.Admitted);
+ Assert.Equal(next.PersonId, remaining.PersonId);
+ }
+ }
+
+ [Fact]
+ public void SameSeedAndEvents_SameQueueOrder()
+ {
+ var firstOrder = QueueOrderFromFight(seed: 42);
+ var secondOrder = QueueOrderFromFight(seed: 42);
+ Assert.Equal(firstOrder, secondOrder);
+ Assert.Equal(2, firstOrder.Count);
+ }
+
+ [Fact]
+ public void WithoutPrincipal_SummonDoesNotHangForever()
+ {
+ var (school, first, second, _) = TwoPupilsAndStaffOnBreak();
+ using (school)
+ {
+ Assert.False(DirectorSummons.HasHiredPrincipal(school.Roster!));
+ BreakFight(school, first, second);
+ Assert.Empty(school.DirectorSummons);
+ Assert.False(DirectorSummonSystem.IsSummoned(school, first.Id));
+ Assert.False(DirectorSummonSystem.TryEnqueue(school, first.Id, chance: 1f));
+ Assert.Empty(school.DirectorSummons);
+ }
+ }
+
+ [Fact]
+ public void PlayerCannotSendSummon_NoProtocolIntent()
+ {
+ Assert.DoesNotContain(
+ Enum.GetNames(),
+ name => name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(
+ typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
+ method => method.Name.Contains("Summon", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void WaitNode_IsCorridorAdjacentToOffice()
+ {
+ var (_, map) = Vanilla();
+ Assert.Equal("principals-office", DirectorSummons.OfficeNodeId(map));
+ Assert.Equal("corridor-1", DirectorSummons.WaitNodeId(map));
+ }
+
+ private static IReadOnlyList QueueOrderFromFight(int seed)
+ {
+ var (school, first, second, _) = TwoPupilsPrincipalAndTeacherOnBreak(seed);
+ using (school)
+ {
+ BreakFight(school, first, second);
+ return school.DirectorSummons
+ .OrderBy(row => row.Order)
+ .Select(row => row.PersonId)
+ .ToArray();
+ }
+ }
+
+ private static void BreakFight(School school, Person first, Person second)
+ {
+ OpinionStore.Set(first, second.Id, -55);
+ OpinionStore.Set(second, first.Id, -55);
+ var staffId = school.Roster!.People.First(person =>
+ person.IsStaff && Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal)).Id;
+ PlaceAt(school, first.Id, "yard");
+ PlaceAt(school, second.Id, "yard");
+ PlaceAt(school, staffId, "yard");
+ Assert.True(school.TryStartAction(first.Id, TalkActions.Fight));
+ PlaceAt(school, staffId, "yard");
+ TalkCircleSystem.Apply(school, 1d);
+ DirectorSummonSystem.Apply(school);
+ }
+
+ private static (School School, Person First, Person Second, string PrincipalId) TwoPupilsPrincipalAndTeacherOnBreak(
+ int seed = 42)
+ {
+ var (school, first, second, _) = TwoPupilsAndStaffOnBreak(seed);
+ var principalId = HirePrincipal(school);
+ first = school.Roster!.People.First(person => person.Id == first.Id);
+ second = school.Roster.People.First(person => person.Id == second.Id);
+ return (school, first, second, principalId);
+ }
+
+ private static (School School, Person First, Person Second, string StaffId) TwoPupilsAndStaffOnBreak(int seed = 42)
+ {
+ var (school, first, second) = TwoPupilsOnBreak(seed);
+ const float cap = 100_000f;
+ var roster = school.Roster!;
+ var pool = school.Applicants!;
+ var hired = Staffing.Hire(
+ school.Catalog!,
+ school.Map!,
+ roster,
+ pool,
+ pool.Applicants[0].Person.Id,
+ Staffing.TeacherPosition,
+ cap);
+ Assert.Equal(StaffingError.None, hired.Error);
+ school.ApplyStaffing(hired.Roster, hired.Pool);
+ first = school.Roster!.People.First(person => person.Id == first.Id);
+ second = school.Roster.People.First(person => person.Id == second.Id);
+ var staffId = school.Roster.People.First(person =>
+ person.IsStaff && Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal)).Id;
+ return (school, first, second, staffId);
+ }
+
+ private static string HirePrincipal(School school)
+ {
+ const float cap = 100_000f;
+ var pool = school.Applicants!;
+ var applicantId = pool.Applicants[0].Person.Id;
+ var hired = Staffing.Hire(
+ school.Catalog!,
+ school.Map!,
+ school.Roster!,
+ pool,
+ applicantId,
+ Staffing.PrincipalPosition,
+ cap);
+ Assert.Equal(StaffingError.None, hired.Error);
+ school.ApplyStaffing(hired.Roster, hired.Pool);
+ return school.Roster!.People.First(person =>
+ Staffing.PrincipalPosition.Equals(person.Position, StringComparison.Ordinal)).Id;
+ }
+
+ private static (School School, Person First, Person Second) TwoPupilsOnBreak(int seed = 42)
+ {
+ var (catalog, map) = Vanilla();
+ var school = OpenSchool(catalog, map, seed, TuesdayMorning, advanceToBreak: true);
+ var schoolClass = school.Roster!.Classes.First(row => row.RoomId == "classroom-101");
+ var pupils = schoolClass.PupilIds
+ .Select(id => school.Roster.People.First(person => person.Id == id))
+ .Take(2)
+ .ToArray();
+ return (school, pupils[0], pupils[1]);
+ }
+
+ private static School OpenSchool(DefCatalog catalog, MapLayout map, int seed, DateTime start, bool advanceToBreak)
+ {
+ 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, "DirectorSummon", 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);
+ if (advanceToBreak)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc));
+ }
+
+ return school;
+ }
+
+ 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 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);
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
index 7a104a2..ba45523 100644
--- a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
+++ b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
@@ -18,6 +18,7 @@
+