Add AI parent meetings after a class last lesson.
Class teachers rarely keep the homeroom; invited parents walk onto the map, opinions nudge, then parents leave OffCampus. No player schedule API.
This commit is contained in:
@@ -12,26 +12,26 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Только ИИ: редкий бросок от сида класса и дня; кнопки игрока нет
|
- [x] Только ИИ: редкий бросок от сида класса и дня; кнопки игрока нет
|
||||||
- [ ] Условия: есть классный, рабочий день, после последнего урока **этого** класса, классный в
|
- [x] Условия: есть классный, рабочий день, после последнего урока **этого** класса, классный в
|
||||||
школе; пороги в `BehaviorDef`
|
школе; пороги в `BehaviorDef`
|
||||||
- [ ] Классный остаётся в homeroom класса
|
- [x] Классный остаётся в homeroom класса
|
||||||
- [ ] Доля родителей класса приходит: получают узел (двор → путь → кабинет), обычная ходьба
|
- [x] Доля родителей класса приходит: получают узел (двор → путь → кабинет), обычная ходьба
|
||||||
- [ ] Учитель-родитель ученика класса, уже в школе, может участвовать без второго инстанса
|
- [x] Учитель-родитель ученика класса, уже в школе, может участвовать без второго инстанса
|
||||||
- [ ] Собрание — ограниченное по времени действие/занятость узла; лёгкий сдвиг мнений
|
- [x] Собрание — ограниченное по времени действие/занятость узла; лёгкий сдвиг мнений
|
||||||
семья↔классный
|
семья↔классный
|
||||||
- [ ] После — родители снова «вне школы»; классный свободен
|
- [x] После — родители снова «вне школы»; классный свободен
|
||||||
- [ ] `EventDef` info «собрание»; локали
|
- [x] `EventDef` info «собрание»; локали
|
||||||
- [ ] Нет классного — собрание не планируется
|
- [x] Нет классного — собрание не планируется
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Без `classTeacherId` собрание не стартует при том же сиде дня
|
- [x] Без `classTeacherId` собрание не стартует при том же сиде дня
|
||||||
- [ ] С классным и прохождением редкости родители появляются на карте и уходят после
|
- [x] С классным и прохождением редкости родители появляются на карте и уходят после
|
||||||
- [ ] Собрание не раньше последнего урока класса
|
- [x] Собрание не раньше последнего урока класса
|
||||||
- [ ] Тот же сид класса/дня → те же «кто пришёл» (детерминизм доли)
|
- [x] Тот же сид класса/дня → те же «кто пришёл» (детерминизм доли)
|
||||||
- [ ] Игрок не назначает собрание по API
|
- [x] Игрок не назначает собрание по API
|
||||||
- [ ] Skip ночи не оставляет родителей вечно в кабинете
|
- [x] Skip ночи не оставляет родителей вечно в кабинете
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure helpers for AI class-teacher parent meetings. Session state lives on the school worker.
|
||||||
|
/// </summary>
|
||||||
|
public static class ParentMeetings
|
||||||
|
{
|
||||||
|
public const string MeetingAction = "ParentMeeting";
|
||||||
|
|
||||||
|
/// <summary>Presence / card label while walking to the homeroom. Not a planner pick.</summary>
|
||||||
|
public const string GoingAction = "GoingToParentMeeting";
|
||||||
|
|
||||||
|
public static DateTime? LastLessonEnd(
|
||||||
|
DefCatalog catalog,
|
||||||
|
Timetable? timetable,
|
||||||
|
SchoolClass schoolClass,
|
||||||
|
DateTime time)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(schoolClass);
|
||||||
|
if (catalog.DayFrame is null || timetable is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var weekday = SchoolDay.WeekdayIndex(time);
|
||||||
|
var lastPeriod = timetable.Lessons
|
||||||
|
.Where(lesson =>
|
||||||
|
lesson.ClassId.Equals(schoolClass.Id, StringComparison.Ordinal)
|
||||||
|
&& lesson.Day == weekday)
|
||||||
|
.Select(lesson => lesson.Period)
|
||||||
|
.DefaultIfEmpty(0)
|
||||||
|
.Max();
|
||||||
|
if (lastPeriod < 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = SchoolDay.PeriodEnd(catalog.DayFrame, lastPeriod);
|
||||||
|
var day = DateTime.SpecifyKind(time, DateTimeKind.Utc).Date;
|
||||||
|
return day + end.ToTimeSpan();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsAfterLastLesson(
|
||||||
|
DefCatalog catalog,
|
||||||
|
Timetable? timetable,
|
||||||
|
SchoolClass schoolClass,
|
||||||
|
DateTime time,
|
||||||
|
int weekDays)
|
||||||
|
{
|
||||||
|
if (!SchoolDay.IsWorkday(catalog, time, weekDays))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = LastLessonEnd(catalog, timetable, schoolClass, time);
|
||||||
|
if (end is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.SpecifyKind(time, DateTimeKind.Utc) >= end.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool RollMeeting(float chance, int seed) =>
|
||||||
|
chance >= 1f || (chance > 0f && TalkCircles.Roll01(seed) < chance);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique parents of the class pupils, ordinal order. Teacher-parents stay in the list — one
|
||||||
|
/// roster id, no second instance.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> ClassParentIds(Roster roster, SchoolClass schoolClass)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
|
ArgumentNullException.ThrowIfNull(schoolClass);
|
||||||
|
|
||||||
|
var pupilSet = schoolClass.PupilIds.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var parents = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var family in roster.Families)
|
||||||
|
{
|
||||||
|
if (!family.ChildIds.Any(childId => pupilSet.Contains(childId)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var parentId in family.ParentIds)
|
||||||
|
{
|
||||||
|
parents.Add(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parents.OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> SelectAttendees(
|
||||||
|
Roster roster,
|
||||||
|
SchoolClass schoolClass,
|
||||||
|
float fraction,
|
||||||
|
int schoolSeed,
|
||||||
|
int dayNumber)
|
||||||
|
{
|
||||||
|
var parents = ClassParentIds(roster, schoolClass);
|
||||||
|
if (parents.Count == 0 || fraction <= 0f)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fraction >= 1f)
|
||||||
|
{
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosen = new List<string>();
|
||||||
|
foreach (var parentId in parents)
|
||||||
|
{
|
||||||
|
var seed = Seed.Mix(schoolSeed, parentId, dayNumber, Seed.MeetingAttendSalt);
|
||||||
|
if (TalkCircles.Roll01(seed) < fraction)
|
||||||
|
{
|
||||||
|
chosen.Add(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -402,6 +402,7 @@ const ru = {
|
|||||||
LessonStarted: 'Начало урока',
|
LessonStarted: 'Начало урока',
|
||||||
GenerationFailed: 'Не удалось нарисовать портрет',
|
GenerationFailed: 'Не удалось нарисовать портрет',
|
||||||
DirectorSummoned: 'Ученика вызвали к директору',
|
DirectorSummoned: 'Ученика вызвали к директору',
|
||||||
|
ParentMeeting: 'Идёт родительское собрание',
|
||||||
noticeDismiss: 'Закрыть',
|
noticeDismiss: 'Закрыть',
|
||||||
noticeGenerateImage: 'Создать картинку',
|
noticeGenerateImage: 'Создать картинку',
|
||||||
noticeGenerateImageBusy: 'Рисуем…',
|
noticeGenerateImageBusy: 'Рисуем…',
|
||||||
@@ -812,6 +813,7 @@ const en: Messages = {
|
|||||||
LessonStarted: 'A lesson has started',
|
LessonStarted: 'A lesson has started',
|
||||||
GenerationFailed: 'Portrait generation failed',
|
GenerationFailed: 'Portrait generation failed',
|
||||||
DirectorSummoned: 'A pupil was summoned to the principal',
|
DirectorSummoned: 'A pupil was summoned to the principal',
|
||||||
|
ParentMeeting: 'A parent meeting is under way',
|
||||||
noticeDismiss: 'Close',
|
noticeDismiss: 'Close',
|
||||||
noticeGenerateImage: 'Create image',
|
noticeGenerateImage: 'Create image',
|
||||||
noticeGenerateImageBusy: 'Drawing…',
|
noticeGenerateImageBusy: 'Drawing…',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function noticeLabel(defName: string): string {
|
|||||||
case 'LessonStarted':
|
case 'LessonStarted':
|
||||||
case 'GenerationFailed':
|
case 'GenerationFailed':
|
||||||
case 'DirectorSummoned':
|
case 'DirectorSummoned':
|
||||||
|
case 'ParentMeeting':
|
||||||
return t(defName satisfies MessageKey);
|
return t(defName satisfies MessageKey);
|
||||||
default:
|
default:
|
||||||
return defName;
|
return defName;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ internal static class EventDefValidator
|
|||||||
EventTriggers.LessonStart,
|
EventTriggers.LessonStart,
|
||||||
EventTriggers.GenerationFailed,
|
EventTriggers.GenerationFailed,
|
||||||
EventTriggers.DirectorSummon,
|
EventTriggers.DirectorSummon,
|
||||||
|
EventTriggers.ParentMeeting,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class EventTriggers
|
|||||||
public const string LessonStart = "lessonStart";
|
public const string LessonStart = "lessonStart";
|
||||||
public const string GenerationFailed = "generationFailed";
|
public const string GenerationFailed = "generationFailed";
|
||||||
public const string DirectorSummon = "directorSummon";
|
public const string DirectorSummon = "directorSummon";
|
||||||
|
public const string ParentMeeting = "parentMeeting";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class EventActions
|
public static class EventActions
|
||||||
|
|||||||
@@ -684,6 +684,24 @@ internal static class PeopleDefValidator
|
|||||||
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
|
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (behavior.ParentMeetingChance is < 0f or > 1f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"BehaviorDef '{behavior.DefName}' parentMeetingChance must be 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (behavior.ParentMeetingAttendanceFraction is < 0f or > 1f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"BehaviorDef '{behavior.DefName}' parentMeetingAttendanceFraction must be 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (behavior.ParentMeetingMinutes <= 0f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"BehaviorDef '{behavior.DefName}' parentMeetingMinutes must be positive.");
|
||||||
|
}
|
||||||
|
|
||||||
if (behavior.LessonMarkMax < 0)
|
if (behavior.LessonMarkMax < 0)
|
||||||
{
|
{
|
||||||
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonMarkMax cannot be negative.");
|
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonMarkMax cannot be negative.");
|
||||||
|
|||||||
@@ -595,6 +595,25 @@ public sealed class BehaviorDef : Def
|
|||||||
/// <summary>Opinion of the Principal after a hearing. Negative — the visit is not praise.</summary>
|
/// <summary>Opinion of the Principal after a hearing. Negative — the visit is not praise.</summary>
|
||||||
public int DirectorHearingOpinionShift { get; init; } = -12;
|
public int DirectorHearingOpinionShift { get; init; } = -12;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chance a class with a class teacher holds a parent meeting after its last lesson.
|
||||||
|
/// Low in vanilla — meetings are rare AI, not a weekly calendar.
|
||||||
|
/// </summary>
|
||||||
|
public float ParentMeetingChance { get; init; } = 0.08f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Independent chance each class parent is invited when a meeting starts. Not 100% attendance.
|
||||||
|
/// </summary>
|
||||||
|
public float ParentMeetingAttendanceFraction { get; init; } = 0.4f;
|
||||||
|
|
||||||
|
/// <summary>Game minutes the class teacher keeps the homeroom for the meeting.</summary>
|
||||||
|
public float ParentMeetingMinutes { get; init; } = 20f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opinion shift both ways between each attending parent and the class teacher. Small and positive.
|
||||||
|
/// </summary>
|
||||||
|
public int ParentMeetingOpinionShift { get; init; } = 3;
|
||||||
|
|
||||||
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
|
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
|
||||||
[
|
[
|
||||||
"quarrel",
|
"quarrel",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public static class Seed
|
|||||||
public const int HomeSalt = 15;
|
public const int HomeSalt = 15;
|
||||||
public const int SummonSalt = 16;
|
public const int SummonSalt = 16;
|
||||||
public const int HealthSalt = 17;
|
public const int HealthSalt = 17;
|
||||||
|
public const int MeetingSalt = 18;
|
||||||
|
public const int MeetingAttendSalt = 19;
|
||||||
|
|
||||||
/// <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);
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ internal static partial class PersonCardReader
|
|||||||
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
||||||
var skills = LiveSkills(school.World, personId);
|
var skills = LiveSkills(school.World, personId);
|
||||||
var activityId = LiveActivity(school.World, personId)
|
var activityId = LiveActivity(school.World, personId)
|
||||||
?? school.DirectorSummonPresenceActionId(personId);
|
?? school.DirectorSummonPresenceActionId(personId)
|
||||||
|
?? school.ParentMeetingPresenceActionId(personId);
|
||||||
string? activityLabel = null;
|
string? activityLabel = null;
|
||||||
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
|
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -155,6 +155,22 @@
|
|||||||
"roles": ["student"],
|
"roles": ["student"],
|
||||||
"weight": 0,
|
"weight": 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Class-teacher parent meeting (slice 12 phase 72). Weight 0 — planner never picks it;
|
||||||
|
// ParentMeetingSystem starts it when the teacher is idle in the homeroom.
|
||||||
|
"defName": "ParentMeeting",
|
||||||
|
"room": "Classroom",
|
||||||
|
"minutes": 20,
|
||||||
|
"roles": ["staff"],
|
||||||
|
"weight": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "GoingToParentMeeting",
|
||||||
|
"room": "Classroom",
|
||||||
|
"minutes": 1,
|
||||||
|
"roles": ["staff", "parent"],
|
||||||
|
"weight": 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defName": "ChangeClothesMale",
|
"defName": "ChangeClothesMale",
|
||||||
"room": "MaleChangingRoom",
|
"room": "MaleChangingRoom",
|
||||||
|
|||||||
@@ -104,6 +104,11 @@
|
|||||||
"directorSummonReprimandChance": 0.35,
|
"directorSummonReprimandChance": 0.35,
|
||||||
"directorHearingMinutes": 5,
|
"directorHearingMinutes": 5,
|
||||||
"directorHearingOpinionShift": -12,
|
"directorHearingOpinionShift": -12,
|
||||||
|
// Parent meetings (slice 12 phase 72). Rare AI after the class's last lesson.
|
||||||
|
"parentMeetingChance": 0.08,
|
||||||
|
"parentMeetingAttendanceFraction": 0.4,
|
||||||
|
"parentMeetingMinutes": 20,
|
||||||
|
"parentMeetingOpinionShift": 3,
|
||||||
// Lesson marks 2–5 from lesson quality (slice 13 phase 73). One mark per slot.
|
// Lesson marks 2–5 from lesson quality (slice 13 phase 73). One mark per slot.
|
||||||
"lessonMarkMax": 40,
|
"lessonMarkMax": 40,
|
||||||
// Inclusive quality floors for 5, 4, 3 (descending). Below the last → 2.
|
// Inclusive quality floors for 5, 4, 3 (descending). Below the last → 2.
|
||||||
|
|||||||
@@ -31,4 +31,12 @@
|
|||||||
"trigger": "directorSummon",
|
"trigger": "directorSummon",
|
||||||
"action": "none",
|
"action": "none",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defName": "ParentMeeting",
|
||||||
|
"severity": "info",
|
||||||
|
"pause": false,
|
||||||
|
"ttlMs": 8000,
|
||||||
|
"trigger": "parentMeeting",
|
||||||
|
"action": "none",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -232,7 +232,9 @@
|
|||||||
"LessonStarted": "A lesson has started",
|
"LessonStarted": "A lesson has started",
|
||||||
"GenerationFailed": "Portrait generation failed",
|
"GenerationFailed": "Portrait generation failed",
|
||||||
"DirectorSummoned": "A pupil was summoned to the principal",
|
"DirectorSummoned": "A pupil was summoned to the principal",
|
||||||
|
"ParentMeeting": "A parent meeting is under way",
|
||||||
"GoingToPrincipal": "going to the principal",
|
"GoingToPrincipal": "going to the principal",
|
||||||
"WaitForPrincipal": "waiting at the principal's office",
|
"WaitForPrincipal": "waiting at the principal's office",
|
||||||
|
"GoingToParentMeeting": "going to a parent meeting",
|
||||||
"core": "Core",
|
"core": "Core",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,9 @@
|
|||||||
"LessonStarted": "Начало урока",
|
"LessonStarted": "Начало урока",
|
||||||
"GenerationFailed": "Не удалось нарисовать портрет",
|
"GenerationFailed": "Не удалось нарисовать портрет",
|
||||||
"DirectorSummoned": "Ученика вызвали к директору",
|
"DirectorSummoned": "Ученика вызвали к директору",
|
||||||
|
"ParentMeeting": "Идёт родительское собрание",
|
||||||
"GoingToPrincipal": "идёт к директору",
|
"GoingToPrincipal": "идёт к директору",
|
||||||
"WaitForPrincipal": "ждёт у кабинета директора",
|
"WaitForPrincipal": "ждёт у кабинета директора",
|
||||||
|
"GoingToParentMeeting": "идёт на собрание",
|
||||||
"core": "Базовая игра",
|
"core": "Базовая игра",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ internal static class ActivitySystem
|
|||||||
DirectorSummonSystem.CompleteHearing(school, identity.Id);
|
DirectorSummonSystem.CompleteHearing(school, identity.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsParentMeeting(action.DefName))
|
||||||
|
{
|
||||||
|
ParentMeetingSystem.CompleteMeeting(school, identity.Id);
|
||||||
|
}
|
||||||
|
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
completed.Add(identity.Id);
|
completed.Add(identity.Id);
|
||||||
});
|
});
|
||||||
@@ -190,6 +195,10 @@ internal static class ActivitySystem
|
|||||||
actionId is not null
|
actionId is not null
|
||||||
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
|
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
internal static bool IsParentMeeting(string? actionId) =>
|
||||||
|
actionId is not null
|
||||||
|
&& actionId.Equals(ParentMeetings.MeetingAction, 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,451 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rare AI parent meetings after a class's last lesson. Only the school worker mutates
|
||||||
|
/// <see cref="School.ActiveParentMeetings"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ParentMeetingSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription PresenceQuery =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||||
|
|
||||||
|
public static bool IsInMeeting(School school, string personId) =>
|
||||||
|
school.ActiveParentMeetings.Any(session =>
|
||||||
|
session.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||||
|
|| session.AttendeeIds.Contains(personId));
|
||||||
|
|
||||||
|
public static bool TryDutyRoom(School school, string personId, out string roomId)
|
||||||
|
{
|
||||||
|
roomId = null!;
|
||||||
|
var session = school.ActiveParentMeetings.FirstOrDefault(row =>
|
||||||
|
row.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||||
|
|| row.AttendeeIds.Contains(personId));
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId = session.HomeroomId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Apply(School school)
|
||||||
|
{
|
||||||
|
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryStartMeetings(school);
|
||||||
|
foreach (var session in school.ActiveParentMeetings.ToArray())
|
||||||
|
{
|
||||||
|
TryStartMeetingAction(school, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start a meeting for one class when rarity and attendance pass. Used by tests with chance 1.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryStart(
|
||||||
|
School school,
|
||||||
|
string classId,
|
||||||
|
float chance,
|
||||||
|
float fraction)
|
||||||
|
{
|
||||||
|
if (school.Roster is null || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var schoolClass = school.Roster.Classes.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(classId, StringComparison.Ordinal));
|
||||||
|
if (schoolClass?.ClassTeacherId is null
|
||||||
|
|| string.IsNullOrWhiteSpace(schoolClass.RoomId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (school.ActiveParentMeetings.Any(row =>
|
||||||
|
row.ClassId.Equals(classId, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ParentMeetings.IsAfterLastLesson(
|
||||||
|
school.Catalog,
|
||||||
|
school.Timetable,
|
||||||
|
schoolClass,
|
||||||
|
school.Clock.Time,
|
||||||
|
school.SchoolWeekDays))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsOnCampus(school, schoolClass.ClassTeacherId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
||||||
|
school.ParentMeetingRolledToday.Add(schoolClass.Id);
|
||||||
|
var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
|
||||||
|
if (!ParentMeetings.RollMeeting(chance, seed))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attendees = ParentMeetings.SelectAttendees(
|
||||||
|
school.Roster,
|
||||||
|
schoolClass,
|
||||||
|
fraction,
|
||||||
|
school.PeopleSeed,
|
||||||
|
dayNumber);
|
||||||
|
if (attendees.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginSession(school, schoolClass, attendees);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearAll(School school)
|
||||||
|
{
|
||||||
|
school.ParentMeetingRolledToday.Clear();
|
||||||
|
if (school.ActiveParentMeetings.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = school.ActiveParentMeetings
|
||||||
|
.SelectMany(session => session.AttendeeIds.Append(session.TeacherId))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
school.ActiveParentMeetings.Clear();
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
ForceOffCampusIfParentOnly(school, id);
|
||||||
|
PresenceSystem.Enqueue(school, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CompleteMeeting(School school, string teacherId)
|
||||||
|
{
|
||||||
|
var session = school.ActiveParentMeetings.FirstOrDefault(row =>
|
||||||
|
row.TeacherId.Equals(teacherId, StringComparison.Ordinal) && row.InProgress);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyOpinionShifts(school, session);
|
||||||
|
school.ActiveParentMeetings.Remove(session);
|
||||||
|
foreach (var parentId in session.AttendeeIds)
|
||||||
|
{
|
||||||
|
SendHome(school, parentId);
|
||||||
|
PresenceSystem.Enqueue(school, parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
PresenceSystem.Enqueue(school, session.TeacherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryStartMeetings(School school)
|
||||||
|
{
|
||||||
|
var roster = school.Roster!;
|
||||||
|
var catalog = school.Catalog!;
|
||||||
|
var rules = catalog.BehaviorRules;
|
||||||
|
var chance = rules?.ParentMeetingChance ?? 0.08f;
|
||||||
|
var fraction = rules?.ParentMeetingAttendanceFraction ?? 0.4f;
|
||||||
|
var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
||||||
|
|
||||||
|
foreach (var schoolClass in roster.Classes.OrderBy(row => row.Id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (schoolClass.ClassTeacherId is null
|
||||||
|
|| string.IsNullOrWhiteSpace(schoolClass.RoomId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (school.ParentMeetingRolledToday.Contains(schoolClass.Id)
|
||||||
|
|| school.ActiveParentMeetings.Any(row =>
|
||||||
|
row.ClassId.Equals(schoolClass.Id, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ParentMeetings.IsAfterLastLesson(
|
||||||
|
catalog,
|
||||||
|
school.Timetable,
|
||||||
|
schoolClass,
|
||||||
|
school.Clock.Time,
|
||||||
|
school.SchoolWeekDays))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsOnCampus(school, schoolClass.ClassTeacherId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
school.ParentMeetingRolledToday.Add(schoolClass.Id);
|
||||||
|
var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
|
||||||
|
if (!ParentMeetings.RollMeeting(chance, seed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attendees = ParentMeetings.SelectAttendees(
|
||||||
|
roster,
|
||||||
|
schoolClass,
|
||||||
|
fraction,
|
||||||
|
school.PeopleSeed,
|
||||||
|
dayNumber);
|
||||||
|
if (attendees.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginSession(school, schoolClass, attendees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BeginSession(
|
||||||
|
School school,
|
||||||
|
SchoolClass schoolClass,
|
||||||
|
IReadOnlyList<string> attendees)
|
||||||
|
{
|
||||||
|
var session = new ParentMeetingSession(
|
||||||
|
schoolClass.Id,
|
||||||
|
schoolClass.ClassTeacherId!,
|
||||||
|
schoolClass.RoomId,
|
||||||
|
attendees);
|
||||||
|
school.ActiveParentMeetings.Add(session);
|
||||||
|
PresenceSystem.Enqueue(school, session.TeacherId);
|
||||||
|
foreach (var parentId in attendees)
|
||||||
|
{
|
||||||
|
PresenceSystem.Enqueue(school, parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
school.RaiseWorldEvent(new WorldEvent(EventTriggers.ParentMeeting, session.TeacherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryStartMeetingAction(School school, ParentMeetingSession session)
|
||||||
|
{
|
||||||
|
if (session.InProgress || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsIdleInRoom(school, session.TeacherId, session.HomeroomId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one invited parent present and idle — teacher-parents already on campus count.
|
||||||
|
var anyParentHere = session.AttendeeIds.Any(id => IsIdleInRoom(school, id, session.HomeroomId));
|
||||||
|
if (!anyParentHere)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!school.Catalog.Actions.TryGetValue(ParentMeetings.MeetingAction, out var action)
|
||||||
|
|| action.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minutes = school.Catalog.BehaviorRules?.ParentMeetingMinutes ?? 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(session.TeacherId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.IsActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = new PersonActivity(ParentMeetings.MeetingAction, null, minutes);
|
||||||
|
intent = Intent.None;
|
||||||
|
session.InProgress = true;
|
||||||
|
started = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyOpinionShifts(School school, ParentMeetingSession session)
|
||||||
|
{
|
||||||
|
var roster = school.Roster!;
|
||||||
|
var rules = school.Catalog?.BehaviorRules;
|
||||||
|
if (rules is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shift = rules.ParentMeetingOpinionShift;
|
||||||
|
if (shift == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var teacher = roster.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(session.TeacherId, StringComparison.Ordinal));
|
||||||
|
if (teacher is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var parentId in session.AttendeeIds.OrderBy(id => id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var parent = roster.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(parentId, StringComparison.Ordinal));
|
||||||
|
if (parent is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentOfTeacher = OpinionStore.Get(parent, session.TeacherId) ?? 0;
|
||||||
|
OpinionStore.Set(parent, session.TeacherId, parentOfTeacher + shift);
|
||||||
|
var teacherOfParent = OpinionStore.Get(teacher, parentId) ?? 0;
|
||||||
|
OpinionStore.Set(teacher, parentId, teacherOfParent + shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
school.RosterTalkDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendHome(School school, string personId)
|
||||||
|
{
|
||||||
|
if (school.Walks is null)
|
||||||
|
{
|
||||||
|
ForceOffCampusIfParentOnly(school, personId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||||
|
// Staff who are also parents stay for the rest of their work day.
|
||||||
|
if (person is { IsStaff: true })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||||
|
{
|
||||||
|
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
|
intent = Intent.None;
|
||||||
|
if (!presence.IsOnCampus)
|
||||||
|
{
|
||||||
|
presence = Presence.OffCampus;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
presence = PresenceStepper.StartWalk(
|
||||||
|
presence,
|
||||||
|
school.Walks,
|
||||||
|
school.Walks.TerritoryId,
|
||||||
|
headingHome: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ForceOffCampusIfParentOnly(School school, string personId)
|
||||||
|
{
|
||||||
|
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||||
|
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||||
|
if (person is { IsStaff: true } || person is { IsStudent: true })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||||
|
{
|
||||||
|
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
presence = Presence.OffCampus;
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
|
intent = Intent.None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOnCampus(School school, string personId)
|
||||||
|
{
|
||||||
|
var onCampus = false;
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity _, ref Intent _) =>
|
||||||
|
{
|
||||||
|
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
onCampus = presence.IsOnCampus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return onCampus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsIdleInRoom(School school, string personId, string roomId)
|
||||||
|
{
|
||||||
|
var found = false;
|
||||||
|
school.World.Query(
|
||||||
|
in PresenceQuery,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
|
||||||
|
{
|
||||||
|
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|
||||||
|
|| (presence.DestinationId is not null
|
||||||
|
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
|
||||||
|
found = !activity.IsActive
|
||||||
|
&& !walking
|
||||||
|
&& presence.NodeId is not null
|
||||||
|
&& presence.NodeId.Equals(roomId, StringComparison.Ordinal);
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One active parent meeting for a class.</summary>
|
||||||
|
internal sealed class ParentMeetingSession(
|
||||||
|
string classId,
|
||||||
|
string teacherId,
|
||||||
|
string homeroomId,
|
||||||
|
IReadOnlyList<string> attendeeIds)
|
||||||
|
{
|
||||||
|
public string ClassId { get; } = classId;
|
||||||
|
|
||||||
|
public string TeacherId { get; } = teacherId;
|
||||||
|
|
||||||
|
public string HomeroomId { get; } = homeroomId;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> AttendeeIds { get; } = attendeeIds;
|
||||||
|
|
||||||
|
public bool InProgress { get; set; }
|
||||||
|
}
|
||||||
@@ -331,7 +331,8 @@ 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))
|
if (DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||||
|
|| ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||||
{
|
{
|
||||||
bound = true;
|
bound = true;
|
||||||
}
|
}
|
||||||
@@ -352,9 +353,24 @@ internal static class PresenceSystem
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!presence.IsOnCampus)
|
if (!presence.IsOnCampus)
|
||||||
{
|
{
|
||||||
intent = Intent.None;
|
intent = Intent.None;
|
||||||
|
if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingArrive))
|
||||||
|
{
|
||||||
|
presence = PresenceStepper.StartWalk(
|
||||||
|
Presence.OffCampus,
|
||||||
|
walks,
|
||||||
|
meetingArrive,
|
||||||
|
headingHome: false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
|
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
|
||||||
{
|
{
|
||||||
var dest = plan.FirstRoom ?? Duty.RoomAt(
|
var dest = plan.FirstRoom ?? Duty.RoomAt(
|
||||||
@@ -379,7 +395,10 @@ internal static class PresenceSystem
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id))
|
if (plan.WalkHomeAt is { } leave
|
||||||
|
&& now >= leave
|
||||||
|
&& !DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||||
|
&& !ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||||
{
|
{
|
||||||
activity = PersonActivity.Idle;
|
activity = PersonActivity.Idle;
|
||||||
intent = Intent.None;
|
intent = Intent.None;
|
||||||
@@ -399,6 +418,11 @@ internal static class PresenceSystem
|
|||||||
duty = summonRoom;
|
duty = summonRoom;
|
||||||
bound = true;
|
bound = true;
|
||||||
}
|
}
|
||||||
|
else if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingRoom))
|
||||||
|
{
|
||||||
|
duty = meetingRoom;
|
||||||
|
bound = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (duty is null)
|
if (duty is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
internal int NextSummonOrder { get; set; }
|
internal int NextSummonOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||||
|
/// </summary>
|
||||||
|
internal List<ParentMeetingSession> ActiveParentMeetings { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Class ids that already rolled a meeting chance today (pass or fail).</summary>
|
||||||
|
internal HashSet<string> ParentMeetingRolledToday { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <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.
|
||||||
@@ -314,6 +322,7 @@ public sealed class School : IDisposable
|
|||||||
Clock.JumpTo(next.Value);
|
Clock.JumpTo(next.Value);
|
||||||
TalkCircleSystem.AbandonAll(this);
|
TalkCircleSystem.AbandonAll(this);
|
||||||
DirectorSummonSystem.ClearAll(this);
|
DirectorSummonSystem.ClearAll(this);
|
||||||
|
ParentMeetingSystem.ClearAll(this);
|
||||||
ResetDayLog();
|
ResetDayLog();
|
||||||
var peopleChanged = TryYearlyIntake(before, next.Value);
|
var peopleChanged = TryYearlyIntake(before, next.Value);
|
||||||
peopleChanged |= TryApplicantRefresh();
|
peopleChanged |= TryApplicantRefresh();
|
||||||
@@ -388,6 +397,7 @@ public sealed class School : IDisposable
|
|||||||
ResetDayLog();
|
ResetDayLog();
|
||||||
// Hung summons do not survive the work morning — same clear as skip empty.
|
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||||
DirectorSummonSystem.ClearAll(this);
|
DirectorSummonSystem.ClearAll(this);
|
||||||
|
ParentMeetingSystem.ClearAll(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||||
@@ -445,6 +455,29 @@ public sealed class School : IDisposable
|
|||||||
public string? DirectorSummonPresenceActionId(string personId) =>
|
public string? DirectorSummonPresenceActionId(string personId) =>
|
||||||
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
|
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Card label while walking to / sitting in a parent meeting without a live activity yet.
|
||||||
|
/// </summary>
|
||||||
|
public string? ParentMeetingPresenceActionId(string personId)
|
||||||
|
{
|
||||||
|
if (!ParentMeetingSystem.IsInMeeting(this, personId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = ActiveParentMeetings.FirstOrDefault(row =>
|
||||||
|
row.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||||
|
|| row.AttendeeIds.Contains(personId));
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.InProgress && session.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||||
|
? HSchool.Ai.ParentMeetings.MeetingAction
|
||||||
|
: HSchool.Ai.ParentMeetings.GoingAction;
|
||||||
|
}
|
||||||
|
|
||||||
private void RecordWorldEvents(DateTime before, DateTime after)
|
private void RecordWorldEvents(DateTime before, DateTime after)
|
||||||
{
|
{
|
||||||
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
|
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
|
||||||
@@ -469,6 +502,7 @@ public sealed class School : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
DirectorSummonSystem.Apply(this);
|
DirectorSummonSystem.Apply(this);
|
||||||
|
ParentMeetingSystem.Apply(this);
|
||||||
|
|
||||||
PersonDayLog.Sync(this);
|
PersonDayLog.Sync(this);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ internal static class TalkCircleSystem
|
|||||||
|
|
||||||
school.ApologyDebts.Clear();
|
school.ApologyDebts.Clear();
|
||||||
DirectorSummonSystem.ClearAll(school);
|
DirectorSummonSystem.ClearAll(school);
|
||||||
|
ParentMeetingSystem.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))
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
public class ParentMeetingBehaviorTests
|
||||||
|
{
|
||||||
|
private readonly CatalogLoader _loader = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VanillaRules_LoadParentMeetingThresholds()
|
||||||
|
{
|
||||||
|
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 = _loader.Load([CatalogLoader.CorePackId], documents);
|
||||||
|
Assert.NotNull(catalog.BehaviorRules);
|
||||||
|
Assert.Equal(0.08f, catalog.BehaviorRules.ParentMeetingChance);
|
||||||
|
Assert.Equal(0.4f, catalog.BehaviorRules.ParentMeetingAttendanceFraction);
|
||||||
|
Assert.Equal(20f, catalog.BehaviorRules.ParentMeetingMinutes);
|
||||||
|
Assert.Equal(3, catalog.BehaviorRules.ParentMeetingOpinionShift);
|
||||||
|
Assert.Contains(
|
||||||
|
catalog.Events.Values,
|
||||||
|
row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentMeetingChanceOutOfRange_FailsTheCatalog()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
[
|
||||||
|
PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"behavior",
|
||||||
|
"rules",
|
||||||
|
"""{ "defName": "Behavior", "parentMeetingChance": 1.5 }"""),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Contains("parentMeetingChance", error.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Protocol;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation.Tests;
|
||||||
|
|
||||||
|
public class ParentMeetingTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutClassTeacher_SameDaySeed_DoesNotStart()
|
||||||
|
{
|
||||||
|
var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: false);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
Assert.Null(schoolClass.ClassTeacherId);
|
||||||
|
Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
|
||||||
|
Assert.Empty(school.ActiveParentMeetings);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
school.DrainWorldEvents(),
|
||||||
|
row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithClassTeacher_ParentsAppearThenLeaveAfterMeeting()
|
||||||
|
{
|
||||||
|
var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
|
||||||
|
var session = Assert.Single(school.ActiveParentMeetings);
|
||||||
|
Assert.NotEmpty(session.AttendeeIds);
|
||||||
|
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
foreach (var parentId in session.AttendeeIds)
|
||||||
|
{
|
||||||
|
PlaceAt(school, parentId, schoolClass.RoomId);
|
||||||
|
Assert.Equal(schoolClass.RoomId, NodeOf(school, parentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ParentMeetingSystem.Apply(school);
|
||||||
|
Assert.True(ActivitySystem.IsParentMeeting(ActivityOf(school, teacherId)));
|
||||||
|
|
||||||
|
ParentMeetingSystem.CompleteMeeting(school, teacherId);
|
||||||
|
Assert.Empty(school.ActiveParentMeetings);
|
||||||
|
|
||||||
|
foreach (var parentId in session.AttendeeIds)
|
||||||
|
{
|
||||||
|
var person = school.Roster!.People.First(row => row.Id == parentId);
|
||||||
|
if (person.IsStaff)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = NodeOf(school, parentId);
|
||||||
|
var headingHome = IsHeadingHome(school, parentId);
|
||||||
|
Assert.True(
|
||||||
|
node is null || headingHome,
|
||||||
|
$"Parent {parentId} should leave campus, node={node}, headingHome={headingHome}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MeetingDoesNotStartBeforeLastLessonOfClass()
|
||||||
|
{
|
||||||
|
var (school, schoolClass, teacherId) = ClassWithParents(assignTeacher: true, advancePastLastLesson: false);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
// Still during period 1 — last lesson has not ended.
|
||||||
|
Assert.False(ParentMeetings.IsAfterLastLesson(
|
||||||
|
school.Catalog!,
|
||||||
|
school.Timetable,
|
||||||
|
schoolClass,
|
||||||
|
school.Clock.Time,
|
||||||
|
school.SchoolWeekDays));
|
||||||
|
Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
|
||||||
|
Assert.Empty(school.ActiveParentMeetings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameClassAndDaySeed_SameAttendees()
|
||||||
|
{
|
||||||
|
var first = AttendeesFromSeed(seed: 42);
|
||||||
|
var second = AttendeesFromSeed(seed: 42);
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.NotEmpty(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerCannotScheduleMeeting_NoProtocolOrPublicApi()
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
Enum.GetNames<MessageType>(),
|
||||||
|
name => name.Contains("ParentMeeting", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("Meeting", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& name.Contains("Parent", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public),
|
||||||
|
method => method.Name.Contains("Meeting", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (method.Name.StartsWith("Set", StringComparison.Ordinal)
|
||||||
|
|| method.Name.StartsWith("Start", StringComparison.Ordinal)
|
||||||
|
|| method.Name.StartsWith("Schedule", StringComparison.Ordinal)
|
||||||
|
|| method.Name.StartsWith("Enqueue", StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkipEmpty_DoesNotLeaveParentsInHomeroom()
|
||||||
|
{
|
||||||
|
var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
|
||||||
|
var session = Assert.Single(school.ActiveParentMeetings);
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
foreach (var parentId in session.AttendeeIds)
|
||||||
|
{
|
||||||
|
PlaceAt(school, parentId, schoolClass.RoomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump past the work window so skip is allowed, with campus cleared of pupils/staff
|
||||||
|
// except we force parents off via ClearAll path used by skip.
|
||||||
|
ParentMeetingSystem.ClearAll(school);
|
||||||
|
Assert.Empty(school.ActiveParentMeetings);
|
||||||
|
foreach (var parentId in session.AttendeeIds)
|
||||||
|
{
|
||||||
|
var person = school.Roster!.People.First(row => row.Id == parentId);
|
||||||
|
if (person.IsStaff || person.IsStudent)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Null(NodeOf(school, parentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartingMeeting_RaisesParentMeetingWorldEvent()
|
||||||
|
{
|
||||||
|
var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
school.DrainWorldEvents();
|
||||||
|
Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f));
|
||||||
|
var fact = Assert.Single(
|
||||||
|
school.DrainWorldEvents(),
|
||||||
|
row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal));
|
||||||
|
Assert.Equal(schoolClass.ClassTeacherId, fact.PersonKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> AttendeesFromSeed(int seed)
|
||||||
|
{
|
||||||
|
var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true, seed: seed);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 0.5f));
|
||||||
|
return Assert.Single(school.ActiveParentMeetings).AttendeeIds.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (School School, SchoolClass Class, string TeacherId) ClassWithParentsAfterLastLesson(
|
||||||
|
bool assignTeacher,
|
||||||
|
int seed = 42) =>
|
||||||
|
ClassWithParents(assignTeacher, advancePastLastLesson: true, seed);
|
||||||
|
|
||||||
|
private static (School School, SchoolClass Class, string TeacherId) ClassWithParents(
|
||||||
|
bool assignTeacher,
|
||||||
|
bool advancePastLastLesson,
|
||||||
|
int seed = 42)
|
||||||
|
{
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var start = TuesdayMorning;
|
||||||
|
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, "ParentMeeting", 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);
|
||||||
|
|
||||||
|
var teacherId = "";
|
||||||
|
if (assignTeacher)
|
||||||
|
{
|
||||||
|
teacherId = HireTeacher(school);
|
||||||
|
var assigned = ClassTeachers.Assign(school.Roster!, schoolClass.Id, teacherId);
|
||||||
|
Assert.Equal(ClassTeacherError.None, assigned.Error);
|
||||||
|
school.ApplyRosterData(assigned.Roster);
|
||||||
|
schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id);
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advancePastLastLesson)
|
||||||
|
{
|
||||||
|
var end = ParentMeetings.LastLessonEnd(catalog, school.Timetable, schoolClass, school.Clock.Time);
|
||||||
|
Assert.NotNull(end);
|
||||||
|
AdvanceTo(school, end.Value.AddMinutes(1));
|
||||||
|
if (assignTeacher)
|
||||||
|
{
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (assignTeacher)
|
||||||
|
{
|
||||||
|
// Mid-lesson: period 1 is still running.
|
||||||
|
var mid = SchoolDay.PeriodStart(catalog.DayFrame!, 1);
|
||||||
|
var midTime = TuesdayMorning.Date + mid.ToTimeSpan() + TimeSpan.FromMinutes(10);
|
||||||
|
AdvanceTo(school, DateTime.SpecifyKind(midTime, DateTimeKind.Utc));
|
||||||
|
PlaceAt(school, teacherId, schoolClass.RoomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id);
|
||||||
|
return (school, schoolClass, teacherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HireTeacher(School school)
|
||||||
|
{
|
||||||
|
const float cap = 100_000f;
|
||||||
|
var pool = school.Applicants!;
|
||||||
|
var hired = Staffing.Hire(
|
||||||
|
school.Catalog!,
|
||||||
|
school.Map!,
|
||||||
|
school.Roster!,
|
||||||
|
pool,
|
||||||
|
pool.Applicants[0].Person.Id,
|
||||||
|
Staffing.TeacherPosition,
|
||||||
|
cap);
|
||||||
|
Assert.Equal(StaffingError.None, hired.Error);
|
||||||
|
school.ApplyStaffing(hired.Roster, hired.Pool);
|
||||||
|
return school.Roster!.People.First(person =>
|
||||||
|
person.IsStaff && Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal)).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bool IsHeadingHome(School school, string personId)
|
||||||
|
{
|
||||||
|
var heading = false;
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
heading = presence.HeadingHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user