From e40f1302484f4b7c70801d9c2828de288c0c1d40 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 11:49:06 +0300 Subject: [PATCH] Auto-summon pupils to the principal after fight breaks, with a corridor FIFO queue. --- docs/phases/12-office/70-director-summons.md | 30 +- src/HSchool.Ai/DirectorSummons.cs | 105 ++++++ src/HSchool.Content/PeopleDefValidator.cs | 18 + src/HSchool.Content/PeopleDefs.cs | 18 + src/HSchool.People/Seed.cs | 1 + src/HSchool.People/Staffing.cs | 2 + .../mods/core/defs/actions/living.jsonc | 9 + .../mods/core/defs/behavior/rules.jsonc | 5 + .../mods/core/localizations/en.jsonc | 1 + .../mods/core/localizations/ru.jsonc | 1 + src/HSchool.Simulation/ActivitySystem.cs | 9 + .../DirectorSummonSystem.cs | 292 ++++++++++++++++ src/HSchool.Simulation/PresenceSystem.cs | 29 +- src/HSchool.Simulation/School.cs | 12 + src/HSchool.Simulation/TalkCircleSystem.cs | 11 + .../DirectorSummonTests.cs | 313 ++++++++++++++++++ .../HSchool.Simulation.Tests.csproj | 1 + 17 files changed, 839 insertions(+), 18 deletions(-) create mode 100644 src/HSchool.Ai/DirectorSummons.cs create mode 100644 src/HSchool.Simulation/DirectorSummonSystem.cs create mode 100644 tests/HSchool.Simulation.Tests/DirectorSummonTests.cs 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 @@ +