Merge branch 'phase/70-director-summons'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,27 +12,27 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Авто-триггер вызова из фактов (обрыв драки / выговор и пороги в `BehaviorDef`); кнопки
|
- [x] Авто-триггер вызова из фактов (обрыв драки / выговор и пороги в `BehaviorDef`); кнопки
|
||||||
игрока нет
|
игрока нет
|
||||||
- [ ] Временное состояние «вызван» — сильная обязанность дойти до `PrincipalsOffice`, пройти
|
- [x] Временное состояние «вызван» — сильная обязанность дойти до `PrincipalsOffice`, пройти
|
||||||
приём, сняться
|
приём, сняться
|
||||||
- [ ] Нет директора в штате или нет узла кабинета — вызов не стартует / гаснет по правилу в данных
|
- [x] Нет директора в штате или нет узла кабинета — вызов не стартует / гаснет по правилу в данных
|
||||||
- [ ] Кабинет занят приёмом → ждут в коридорном узле у ребра к кабинету, не внутри
|
- [x] Кабинет занят приёмом → ждут в коридорном узле у ребра к кабинету, не внутри
|
||||||
- [ ] В кабинете один на приём; очередь FIFO с детерминированным порядком
|
- [x] В кабинете один на приём; очередь FIFO с детерминированным порядком
|
||||||
- [ ] Приём — короткое действие; мнение о директоре падает; при необходимости допись в список
|
- [x] Приём — короткое действие; мнение о директоре падает; при необходимости допись в список
|
||||||
проступков (69)
|
проступков (69)
|
||||||
- [ ] Обычная ходьба и `travelMinutes`; телепорта нет
|
- [x] Обычная ходьба и `travelMinutes`; телепорта нет
|
||||||
- [ ] Skip / утро не оставляют вечный «вызван» без разрешения (явное правило в коде фазы)
|
- [x] Skip / утро не оставляют вечный «вызван» без разрешения (явное правило в коде фазы)
|
||||||
- [ ] Очередь и вызовы только на потоке школы (инвариант 3)
|
- [x] Очередь и вызовы только на потоке школы (инвариант 3)
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] После qualifying-проступка ученик получает обязанность к кабинету (при живом директоре)
|
- [x] После qualifying-проступка ученик получает обязанность к кабинету (при живом директоре)
|
||||||
- [ ] Двое вызванных: один в кабинете, второй в коридоре у входа, не оба внутри
|
- [x] Двое вызванных: один в кабинете, второй в коридоре у входа, не оба внутри
|
||||||
- [ ] Освобождение кабинета впускает следующего из очереди
|
- [x] Освобождение кабинета впускает следующего из очереди
|
||||||
- [ ] Тот же сид и те же события → тот же порядок очереди
|
- [x] Тот же сид и те же события → тот же порядок очереди
|
||||||
- [ ] Без директора вызов не зависает навечно
|
- [x] Без директора вызов не зависает навечно
|
||||||
- [ ] Игрок не может послать вызов по HTTP/сокету (нет такого намерения)
|
- [x] Игрок не может послать вызов по HTTP/сокету (нет такого намерения)
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [69. Память проступков](69-offense-memory.md) | ✅ | Короткий список на человеке и карточке |
|
| [69. Память проступков](69-offense-memory.md) | ✅ | Короткий список на человеке и карточке |
|
||||||
| [70. Вызов и очередь](70-director-summons.md) | 🔄 | Авто, ходьба, коридор, один в кабинете |
|
| [70. Вызов и очередь](70-director-summons.md) | ✅ | Авто, ходьба, коридор, один в кабинете |
|
||||||
| [71. Тосты вызова](71-summons-notices.md) | ⬜ | `EventDef` info; очередь видна |
|
| [71. Тосты вызова](71-summons-notices.md) | ⬜ | `EventDef` info; очередь видна |
|
||||||
|
|
||||||
69 стоит на 44; 70 — на 69 и ходьбу/решения; 71 — на 70 и 64.
|
69 стоит на 44; 70 — на 69 и ходьбу/решения; 71 — на 70 и 64.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -665,6 +665,24 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryKinds cannot contain empty ids.");
|
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)
|
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
|
||||||
|
|||||||
@@ -577,6 +577,24 @@ public sealed class BehaviorDef : Def
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<string> OffenseMemoryKinds { get; init; } = DefaultOffenseMemoryKinds;
|
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; } =
|
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
|
||||||
[
|
[
|
||||||
"quarrel",
|
"quarrel",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public static class Seed
|
|||||||
public const int OrientationSalt = 13;
|
public const int OrientationSalt = 13;
|
||||||
public const int ConflictSalt = 14;
|
public const int ConflictSalt = 14;
|
||||||
public const int HomeSalt = 15;
|
public const int HomeSalt = 15;
|
||||||
|
public const int SummonSalt = 16;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <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);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public static class Staffing
|
|||||||
{
|
{
|
||||||
public const string TeacherPosition = "Teacher";
|
public const string TeacherPosition = "Teacher";
|
||||||
|
|
||||||
|
public const string PrincipalPosition = "Principal";
|
||||||
|
|
||||||
public static StaffingOutcome UnknownSchool() =>
|
public static StaffingOutcome UnknownSchool() =>
|
||||||
Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0);
|
Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0);
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,15 @@
|
|||||||
"roles": ["student"],
|
"roles": ["student"],
|
||||||
"weight": 0,
|
"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",
|
"defName": "ChangeClothesMale",
|
||||||
"room": "MaleChangingRoom",
|
"room": "MaleChangingRoom",
|
||||||
|
|||||||
@@ -99,4 +99,9 @@
|
|||||||
// Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score.
|
// Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score.
|
||||||
"offenseMemoryMax": 5,
|
"offenseMemoryMax": 5,
|
||||||
"offenseMemoryKinds": ["quarrel", "fight", "reprimand"],
|
"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",
|
"OffenseQuarrel": "quarrel",
|
||||||
"OffenseFight": "fight",
|
"OffenseFight": "fight",
|
||||||
"OffenseReprimand": "reprimand",
|
"OffenseReprimand": "reprimand",
|
||||||
|
"PrincipalHearing": "Principal's hearing",
|
||||||
"TopicStudy": "schoolwork",
|
"TopicStudy": "schoolwork",
|
||||||
"TopicGames": "games",
|
"TopicGames": "games",
|
||||||
"TopicFood": "food",
|
"TopicFood": "food",
|
||||||
|
|||||||
@@ -211,6 +211,7 @@
|
|||||||
"OffenseQuarrel": "ссора",
|
"OffenseQuarrel": "ссора",
|
||||||
"OffenseFight": "драка",
|
"OffenseFight": "драка",
|
||||||
"OffenseReprimand": "выговор",
|
"OffenseReprimand": "выговор",
|
||||||
|
"PrincipalHearing": "Приём у директора",
|
||||||
"TopicStudy": "учёбе",
|
"TopicStudy": "учёбе",
|
||||||
"TopicGames": "играх",
|
"TopicGames": "играх",
|
||||||
"TopicFood": "еде",
|
"TopicFood": "еде",
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ internal static class ActivitySystem
|
|||||||
FinishChangeClothes(school, identity.Id);
|
FinishChangeClothes(school, identity.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsPrincipalHearing(action.DefName))
|
||||||
|
{
|
||||||
|
DirectorSummonSystem.CompleteHearing(school, identity.Id);
|
||||||
|
}
|
||||||
|
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
completed.Add(identity.Id);
|
completed.Add(identity.Id);
|
||||||
});
|
});
|
||||||
@@ -181,6 +186,10 @@ internal static class ActivitySystem
|
|||||||
&& (actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal)
|
&& (actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal)
|
||||||
|| actionId.Equals(ApparelActions.ChangeFemale, 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)
|
private static int Occupied(School school, string nodeId, string thing)
|
||||||
{
|
{
|
||||||
var occupied = 0;
|
var occupied = 0;
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto director summons: enqueue from misconduct, FIFO corridor wait, one hearing in the office.
|
||||||
|
/// Only the school worker thread mutates <see cref="School.DirectorSummons"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DirectorSummonSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription PresenceQuery =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||||
|
|
||||||
|
public static bool IsSummoned(School school, string personId) =>
|
||||||
|
school.DirectorSummons.Any(row => row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
public static bool TryDutyRoom(School school, string personId, out string roomId)
|
||||||
|
{
|
||||||
|
roomId = null!;
|
||||||
|
var ticket = school.DirectorSummons.FirstOrDefault(row =>
|
||||||
|
row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||||
|
if (ticket is null || school.Map is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var office = DirectorSummons.OfficeNodeId(school.Map);
|
||||||
|
var wait = DirectorSummons.WaitNodeId(school.Map, office);
|
||||||
|
if (office is null || wait is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId = ticket.Admitted ? office : wait;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After a staff-broken fight. Pupils only; order is ordinal so the same fight always queues
|
||||||
|
/// the same way.
|
||||||
|
/// </summary>
|
||||||
|
public static void TryEnqueueAfterFightBreak(School school, IReadOnlyList<Person> people)
|
||||||
|
{
|
||||||
|
var rules = school.Catalog?.BehaviorRules;
|
||||||
|
var chance = rules?.DirectorSummonFightBreakChance ?? 1f;
|
||||||
|
foreach (var person in people.OrderBy(row => row.Id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!person.IsStudent)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryEnqueue(school, person.Id, chance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Teacher discipline / caught whisper. Pupils only.</summary>
|
||||||
|
public static void TryEnqueueAfterReprimand(School school, IEnumerable<string> pupilIds)
|
||||||
|
{
|
||||||
|
var rules = school.Catalog?.BehaviorRules;
|
||||||
|
var chance = rules?.DirectorSummonReprimandChance ?? 0.35f;
|
||||||
|
foreach (var id in pupilIds.OrderBy(row => row, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(id, StringComparison.Ordinal));
|
||||||
|
if (person is null || !person.IsStudent)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryEnqueue(school, id, chance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryEnqueue(School school, string personId, float chance)
|
||||||
|
{
|
||||||
|
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectorSummons.CanStart(school.Roster, school.Map))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsSummoned(school, personId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seed = Seed.Mix(
|
||||||
|
school.PeopleSeed,
|
||||||
|
personId,
|
||||||
|
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
|
||||||
|
Seed.SummonSalt);
|
||||||
|
if (!DirectorSummons.RollSummon(chance, seed))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
school.DirectorSummons.Add(new DirectorSummonTicket(personId, school.NextSummonOrder++, admitted: false));
|
||||||
|
PresenceSystem.Enqueue(school, personId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearAll(School school)
|
||||||
|
{
|
||||||
|
if (school.DirectorSummons.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = school.DirectorSummons.Select(row => row.PersonId).ToArray();
|
||||||
|
school.DirectorSummons.Clear();
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
PresenceSystem.Enqueue(school, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drop hung tickets when the Principal is gone or the office vanished, then admit the head
|
||||||
|
/// of the FIFO and start a hearing for whoever is idle in the office.
|
||||||
|
/// </summary>
|
||||||
|
public static void Apply(School school)
|
||||||
|
{
|
||||||
|
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectorSummons.CanStart(school.Roster, school.Map))
|
||||||
|
{
|
||||||
|
ClearAll(school);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (school.DirectorSummons.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdmitHead(school);
|
||||||
|
TryStartHearing(school);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CompleteHearing(School school, string personId)
|
||||||
|
{
|
||||||
|
var ticket = school.DirectorSummons.FirstOrDefault(row =>
|
||||||
|
row.PersonId.Equals(personId, StringComparison.Ordinal));
|
||||||
|
if (ticket is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
school.DirectorSummons.Remove(ticket);
|
||||||
|
ApplyHearingEffects(school, personId);
|
||||||
|
PresenceSystem.Enqueue(school, personId);
|
||||||
|
AdmitHead(school);
|
||||||
|
foreach (var waiting in school.DirectorSummons)
|
||||||
|
{
|
||||||
|
PresenceSystem.Enqueue(school, waiting.PersonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AdmitHead(School school)
|
||||||
|
{
|
||||||
|
if (school.DirectorSummons.Any(row => row.Admitted))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var head = school.DirectorSummons.OrderBy(row => row.Order).FirstOrDefault();
|
||||||
|
if (head is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
head.Admitted = true;
|
||||||
|
PresenceSystem.Enqueue(school, head.PersonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryStartHearing(School school)
|
||||||
|
{
|
||||||
|
var admitted = school.DirectorSummons.FirstOrDefault(row => row.Admitted);
|
||||||
|
if (admitted is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var office = DirectorSummons.OfficeNodeId(school.Map!);
|
||||||
|
if (office is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? nodeId = null;
|
||||||
|
var walking = false;
|
||||||
|
var busy = false;
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
|
||||||
|
{
|
||||||
|
if (!identity.Id.Equals(admitted.PersonId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeId = presence.NodeId;
|
||||||
|
walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|
||||||
|
|| (presence.DestinationId is not null
|
||||||
|
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
|
||||||
|
busy = activity.IsActive;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (busy
|
||||||
|
|| walking
|
||||||
|
|| nodeId is null
|
||||||
|
|| !nodeId.Equals(office, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!school.Catalog!.Actions.TryGetValue(DirectorSummons.HearingAction, out var action)
|
||||||
|
|| action.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minutes = school.Catalog.BehaviorRules?.DirectorHearingMinutes ?? action.Minutes;
|
||||||
|
if (minutes <= 0f)
|
||||||
|
{
|
||||||
|
minutes = action.Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
var started = false;
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||||
|
{
|
||||||
|
if (started || !identity.Id.Equals(admitted.PersonId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.IsActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = new PersonActivity(DirectorSummons.HearingAction, null, minutes);
|
||||||
|
intent = Intent.None;
|
||||||
|
started = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyHearingEffects(School school, string personId)
|
||||||
|
{
|
||||||
|
var person = school.Roster!.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||||
|
var principalId = DirectorSummons.PrincipalId(school.Roster);
|
||||||
|
var rules = school.Catalog!.BehaviorRules;
|
||||||
|
if (person is null || principalId is null || rules is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = OpinionStore.Get(person, principalId) ?? 0;
|
||||||
|
OpinionStore.Set(person, principalId, current + rules.DirectorHearingOpinionShift);
|
||||||
|
OffenseMemory.Record(
|
||||||
|
person,
|
||||||
|
OffenseKinds.Reprimand,
|
||||||
|
school.Clock.Time,
|
||||||
|
principalId,
|
||||||
|
rules);
|
||||||
|
school.RosterTalkDirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One auto summons ticket. <see cref="Order"/> is FIFO; lower goes first.</summary>
|
||||||
|
internal sealed class DirectorSummonTicket(string personId, int order, bool admitted)
|
||||||
|
{
|
||||||
|
public string PersonId { get; } = personId;
|
||||||
|
|
||||||
|
public int Order { get; } = order;
|
||||||
|
|
||||||
|
public bool Admitted { get; set; } = admitted;
|
||||||
|
}
|
||||||
@@ -331,6 +331,11 @@ internal static class PresenceSystem
|
|||||||
var bound = Duty.IsOtherStaff(person)
|
var bound = Duty.IsOtherStaff(person)
|
||||||
? slot.Kind != DaySlotKind.Outside
|
? slot.Kind != DaySlotKind.Outside
|
||||||
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
|
: 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 (activity.IsActive && TalkActions.IsTalk(activity.ActionId))
|
||||||
{
|
{
|
||||||
if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId))
|
if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId))
|
||||||
@@ -342,6 +347,11 @@ internal static class PresenceSystem
|
|||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!presence.IsOnCampus)
|
if (!presence.IsOnCampus)
|
||||||
{
|
{
|
||||||
intent = Intent.None;
|
intent = Intent.None;
|
||||||
@@ -354,6 +364,11 @@ internal static class PresenceSystem
|
|||||||
school.Catalog!,
|
school.Catalog!,
|
||||||
now,
|
now,
|
||||||
school.SchoolWeekDays);
|
school.SchoolWeekDays);
|
||||||
|
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonArrive))
|
||||||
|
{
|
||||||
|
dest = summonArrive;
|
||||||
|
}
|
||||||
|
|
||||||
presence = dest is null
|
presence = dest is null
|
||||||
? Presence.OffCampus
|
? Presence.OffCampus
|
||||||
: PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
|
: PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
|
||||||
@@ -364,7 +379,7 @@ internal static class PresenceSystem
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.WalkHomeAt is { } leave && now >= leave)
|
if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id))
|
||||||
{
|
{
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
intent = Intent.None;
|
intent = Intent.None;
|
||||||
@@ -379,6 +394,12 @@ internal static class PresenceSystem
|
|||||||
school.Catalog!,
|
school.Catalog!,
|
||||||
now,
|
now,
|
||||||
school.SchoolWeekDays);
|
school.SchoolWeekDays);
|
||||||
|
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonRoom))
|
||||||
|
{
|
||||||
|
duty = summonRoom;
|
||||||
|
bound = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (duty is null)
|
if (duty is null)
|
||||||
{
|
{
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
@@ -416,18 +437,20 @@ internal static class PresenceSystem
|
|||||||
|
|
||||||
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
|
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
|
||||||
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
|
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
|
||||||
|
var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
|
||||||
|
|
||||||
if (decision.WalkTo is not null
|
if (decision.WalkTo is not null
|
||||||
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
||||||
&& activity.IsActive
|
&& activity.IsActive
|
||||||
&& !changing
|
&& !changing
|
||||||
&& !inTalk)
|
&& !inTalk
|
||||||
|
&& !inHearing)
|
||||||
{
|
{
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
intent = decision.Intent;
|
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);
|
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
internal Dictionary<string, ApologyDebt> ApologyDebts { get; } = new(StringComparer.Ordinal);
|
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>
|
/// <summary>
|
||||||
/// A finished or interrupted circle wrote opinions onto the roster. <see cref="Tick"/>
|
/// 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.
|
/// 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;
|
var before = Clock.Time;
|
||||||
Clock.JumpTo(next.Value);
|
Clock.JumpTo(next.Value);
|
||||||
TalkCircleSystem.AbandonAll(this);
|
TalkCircleSystem.AbandonAll(this);
|
||||||
|
DirectorSummonSystem.ClearAll(this);
|
||||||
ResetDayLog();
|
ResetDayLog();
|
||||||
var peopleChanged = TryYearlyIntake(before, next.Value);
|
var peopleChanged = TryYearlyIntake(before, next.Value);
|
||||||
peopleChanged |= TryApplicantRefresh();
|
peopleChanged |= TryApplicantRefresh();
|
||||||
@@ -362,6 +370,8 @@ public sealed class School : IDisposable
|
|||||||
if (PersonDayLog.CrossedDayStart(before, Clock.Time))
|
if (PersonDayLog.CrossedDayStart(before, Clock.Time))
|
||||||
{
|
{
|
||||||
ResetDayLog();
|
ResetDayLog();
|
||||||
|
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||||
|
DirectorSummonSystem.ClearAll(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||||
@@ -428,6 +438,8 @@ public sealed class School : IDisposable
|
|||||||
PresenceSystem.Enqueue(this, id);
|
PresenceSystem.Enqueue(this, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DirectorSummonSystem.Apply(this);
|
||||||
|
|
||||||
PersonDayLog.Sync(this);
|
PersonDayLog.Sync(this);
|
||||||
|
|
||||||
if (Catalog is not null)
|
if (Catalog is not null)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ internal static class TalkCircleSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
school.ApologyDebts.Clear();
|
school.ApologyDebts.Clear();
|
||||||
|
DirectorSummonSystem.ClearAll(school);
|
||||||
school.World.Query(in People, (ref PersonActivity activity) =>
|
school.World.Query(in People, (ref PersonActivity activity) =>
|
||||||
{
|
{
|
||||||
if (TalkActions.IsConflict(activity.ActionId))
|
if (TalkActions.IsConflict(activity.ActionId))
|
||||||
@@ -546,6 +547,14 @@ internal static class TalkCircleSystem
|
|||||||
|
|
||||||
school.TalkCirclesById.Remove(circle.Id);
|
school.TalkCirclesById.Remove(circle.Id);
|
||||||
school.RosterTalkDirty = true;
|
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)
|
private static bool TryCatchWhisper(School school, ActiveTalkCircle circle)
|
||||||
@@ -1008,6 +1017,8 @@ internal static class TalkCircleSystem
|
|||||||
PersonLogTypes.Reprimanded,
|
PersonLogTypes.Reprimanded,
|
||||||
circle.ActionId));
|
circle.ActionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DirectorSummonSystem.TryEnqueueAfterFightBreak(school, people);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<MessageType>(),
|
||||||
|
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<string> 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<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||||
|
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<PersonIdentity, Presence>();
|
||||||
|
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<PersonIdentity, PersonActivity>();
|
||||||
|
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<ContentDocument>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
||||||
<ProjectReference Include="..\..\src\HSchool.Schedule\HSchool.Schedule.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
<ProjectReference Include="..\..\src\HSchool.Ai\HSchool.Ai.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Ai\HSchool.Ai.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user