Files
h-school/src/HSchool.Simulation/PresenceSystem.cs
T
Leonid Pershin 3288287da9 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.
2026-08-21 13:33:40 +03:00

824 lines
26 KiB
C#

using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// Decisions go through <see cref="HSchool.Ai"/>; the per-tick walk does not. Order is the
/// roster id list, never Arch's entity order.
/// </summary>
internal static class PresenceSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonNeeds, PersonTraits, Presence, PersonActivity, Intent>();
public static void Apply(School school, double gameMinutes)
{
if (school.Catalog is null || school.Walks is null || school.Roster is null)
{
return;
}
EnsurePlans(school);
school.DecisionBudget = school.MaxDecisionsPerTick;
EnqueueEvents(school);
EnqueueTimeEvents(school, (float)gameMinutes);
DrainDecisions(school);
Move(school, (float)gameMinutes);
FinishHome(school);
DrainDecisions(school);
}
public static IReadOnlySet<string> BelowThreshold(School school)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
if (school.Catalog?.BehaviorRules is null)
{
return ids;
}
var threshold = school.Catalog.BehaviorRules.NeedThreshold;
var world = school.World;
var query = new QueryDescription().WithAll<PersonIdentity, PersonNeeds, Presence>();
world.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
return;
}
foreach (var def in school.Catalog.Needs.Values)
{
if (!def.Abstract && needs.Values.TryGetValue(def.DefName, out var value) && value < threshold)
{
ids.Add(identity.Id);
return;
}
}
});
return ids;
}
public static void EnqueueNewlyUrgent(School school, IReadOnlySet<string> previouslyBelow)
{
foreach (var id in BelowThreshold(school))
{
if (!previouslyBelow.Contains(id))
{
school.DecisionQueue.Enqueue(id);
}
}
}
public static void Enqueue(School school, string personId) => school.DecisionQueue.Enqueue(personId);
public static bool IsEmpty(School school)
{
var empty = true;
var world = school.World;
world.Query(in People, (ref Presence presence) =>
{
if (presence.IsOnCampus)
{
empty = false;
}
});
return empty;
}
public static IReadOnlyList<PresenceSnapshot> Capture(School school)
{
var rows = new List<PresenceSnapshot>();
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
rows.Add(new PresenceSnapshot(
identity.Id,
presence.NodeId,
presence.RemainingMinutes,
presence.DestinationId,
presence.HeadingHome,
presence.Path,
activity.ActionId,
activity.Thing,
activity.RemainingMinutes,
intent.Kind.ToString(),
intent.Id,
intent.Weight,
intent.ActionId));
});
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
return rows;
}
public static void Restore(School school, IReadOnlyList<PresenceSnapshot>? saved)
{
// A new school has no snapshot: people stay off campus and walk in. Missing ids inside
// a real snapshot are hires and intake, and those land on their duty room.
if (saved is null)
{
return;
}
var byId = saved
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
{
if (!byId.TryGetValue(person.Id, out var row))
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
intent = Intent.None;
return;
}
if (row.NodeId is null)
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
intent = Intent.None;
return;
}
presence = new Presence(
row.NodeId,
row.RemainingMinutes,
row.DestinationId,
row.HeadingHome,
row.Path?.ToArray() ?? []);
activity = string.IsNullOrWhiteSpace(row.ActionId)
? PersonActivity.Idle
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
intent = ParseIntent(row);
});
}
public static void PlaceMissingByDuty(School school)
{
ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
{
if (!presence.IsOnCampus)
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
intent = Intent.None;
}
});
}
public static void DrainDecisions(School school)
{
while (school.DecisionBudget > 0 && school.DecisionQueue.Count > 0)
{
var id = school.DecisionQueue.Dequeue();
Decide(school, id);
school.DecisionBudget--;
}
}
private static Presence PlaceByDuty(School school, Person person)
{
var room = Duty.RoomAt(
person,
ClassOf(school, person),
school.Timetable,
school.Catalog!,
school.Clock.Time,
school.SchoolWeekDays);
if (room is null || school.Walks is null)
{
return Presence.OffCampus;
}
return new Presence(room, 0f, room, false, []);
}
private static void EnsurePlans(School school)
{
var day = DateOnly.FromDateTime(school.Clock.Time);
if (school.PlanDay == day && school.Plans.Count == school.Roster!.People.Count)
{
return;
}
school.PlanDay = day;
school.Plans.Clear();
foreach (var person in school.Roster!.People)
{
school.Plans[person.Id] = DayPlans.Build(
school.Catalog!,
school.Walks!,
person,
ClassOf(school, person),
school.Timetable,
school.Clock.Time,
school.SchoolWeekDays,
school.PeopleSeed,
school.Weather.ExtraCommuteMinutes(school.Catalog!.BehaviorRules));
}
school.DecisionQueue.Clear();
foreach (var person in OrderedPeople(school))
{
school.DecisionQueue.Enqueue(person.Id);
}
}
private static void EnqueueEvents(School school)
{
var slot = SchoolDay.At(school.Catalog!, school.Clock.Time, school.SchoolWeekDays);
if (school.LastDecisionSlot == slot)
{
return;
}
school.LastDecisionSlot = slot;
foreach (var person in OrderedPeople(school))
{
school.DecisionQueue.Enqueue(person.Id);
}
}
private static void EnqueueTimeEvents(School school, float minutes)
{
if (minutes <= 0)
{
return;
}
var now = school.Clock.Time;
var previous = now.AddMinutes(-minutes);
foreach (var person in OrderedPeople(school))
{
if (!school.Plans.TryGetValue(person.Id, out var plan))
{
continue;
}
if (plan.AppearAt is { } appear && previous < appear && now >= appear)
{
school.DecisionQueue.Enqueue(person.Id);
}
if (plan.WalkHomeAt is { } leave && previous < leave && now >= leave)
{
school.DecisionQueue.Enqueue(person.Id);
}
}
}
private static void Decide(School school, string personId)
{
var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
if (person is null || !school.Plans.TryGetValue(personId, out var plan))
{
return;
}
var occupied = SnapshotOccupied(school, personId);
string? startAction = null;
var world = school.World;
var found = false;
world.Query(
in People,
(ref PersonIdentity identity, ref PersonRoles roles, ref PersonNeeds needs, ref PersonTraits traits, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
found = true;
_ = traits;
startAction = ApplyDecision(
school,
person,
plan,
occupied,
ref presence,
ref activity,
ref intent,
roles,
needs);
});
if (startAction is not null)
{
ActivitySystem.TryStart(school, person.Id, startAction);
}
}
private static string? ApplyDecision(
School school,
Person person,
DayPlan plan,
Dictionary<(string Node, string Thing), int> occupied,
ref Presence presence,
ref PersonActivity activity,
ref Intent intent,
PersonRoles roles,
PersonNeeds needs)
{
var now = school.Clock.Time;
var walks = school.Walks!;
var slot = SchoolDay.At(school.Catalog!, now, school.SchoolWeekDays);
var weekday = SchoolDay.WeekdayIndex(now);
var lessons = Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday);
var bound = Duty.IsOtherStaff(person)
? slot.Kind != DaySlotKind.Outside
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
if (DirectorSummonSystem.IsSummoned(school, person.Id)
|| ParentMeetingSystem.IsInMeeting(school, person.Id))
{
bound = true;
}
if (activity.IsActive && TalkActions.IsTalk(activity.ActionId))
{
if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId))
{
return null;
}
TalkCircleSystem.Interrupt(school, person.Id);
activity = PersonActivity.Idle;
}
if (activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId))
{
return null;
}
if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId))
{
return null;
}
if (!presence.IsOnCampus)
{
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))
{
var dest = plan.FirstRoom ?? Duty.RoomAt(
person,
ClassOf(school, person),
school.Timetable,
school.Catalog!,
now,
school.SchoolWeekDays);
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonArrive))
{
dest = summonArrive;
}
presence = dest is null
? Presence.OffCampus
: PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
return null;
}
presence = Presence.OffCampus;
return null;
}
if (plan.WalkHomeAt is { } leave
&& now >= leave
&& !DirectorSummonSystem.IsSummoned(school, person.Id)
&& !ParentMeetingSystem.IsInMeeting(school, person.Id))
{
activity = PersonActivity.Idle;
intent = Intent.None;
presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
return null;
}
var duty = Duty.RoomAt(
person,
ClassOf(school, person),
school.Timetable,
school.Catalog!,
now,
school.SchoolWeekDays);
if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonRoom))
{
duty = summonRoom;
bound = true;
}
else if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingRoom))
{
duty = meetingRoom;
bound = true;
}
if (duty is null)
{
activity = PersonActivity.Idle;
intent = Intent.None;
presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
return null;
}
var frame = school.Catalog!.DayFrame;
var lunchOpen = frame is not null
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
var talk = BuildTalkContext(school, person, bound, lunchOpen, slot);
var state = new ActorState(
presence.NodeId,
presence.DestinationId,
presence.Path.Length > 0 || presence.RemainingMinutes > 0,
activity.IsActive,
roles.IsStudent,
roles.IsStaff,
roles.IsParent,
bound,
duty,
needs.Values,
intent,
lunchOpen,
apparel,
talk);
var decision = DecisionPlanner.Decide(
school.Catalog!,
school.Map!,
walks,
state,
(node, thing) => occupied.GetValueOrDefault((node, thing)));
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
if (decision.WalkTo is not null
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
&& activity.IsActive
&& !changing
&& !inTalk
&& !inHearing)
{
activity = PersonActivity.Idle;
}
intent = decision.Intent;
if (decision.WalkTo is not null && !changing && !inTalk && !inHearing)
{
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
}
if (decision.StartAction is not null && !activity.IsActive)
{
var start = decision.StartAction;
var location = state.NodeId is null ? null : school.Map?.NodeDef(state.NodeId);
if (TalkActions.IsQuarrel(start)
&& location is not null
&& Conflict.CanFightHere(location)
&& Conflict.RollFight(
person.Traits,
school.Catalog!,
school.Catalog.BehaviorRules,
Seed.Mix(
school.PeopleSeed,
person.Id,
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
Seed.ConflictSalt + 2)))
{
start = Conflict.FightActionFor(location);
}
return start;
}
if (!activity.IsActive
&& decision.WalkTo is null
&& ShouldStartWhisper(school, person, state))
{
return TalkActions.Whisper;
}
if (!activity.IsActive
&& decision.WalkTo is null
&& ShouldStartFightOnPe(school, person, state))
{
return TalkActions.FightGym;
}
if (!activity.IsActive
&& decision.WalkTo is null
&& ShouldStartApology(school, person, state))
{
return TalkActions.Apologize;
}
return null;
}
private static bool ShouldStartWhisper(School school, Person person, ActorState state)
{
if (!state.BoundToLesson
|| !person.IsStudent
|| state.IsWalking
|| state.NodeId is null
|| state.DutyRoom is null
|| !state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal))
{
return false;
}
var location = school.Map?.NodeDef(state.NodeId);
if (location is null || !location.Equals("Classroom", StringComparison.Ordinal))
{
return false;
}
var rules = school.Catalog?.BehaviorRules;
var chance = (rules?.WhisperStartChance ?? 0.12f) * TalkCircles.Initiative(person.Traits, school.Catalog!);
var roll = TalkCircles.Roll01(
Seed.Mix(
school.PeopleSeed,
person.Id,
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
Seed.WhisperSalt + (school.Clock.Time.Minute / 2)));
return TalkCircles.WhisperCaught(roll, chance, 0.5f, 1f);
}
private static bool ShouldStartFightOnPe(School school, Person person, ActorState state)
{
if (!state.BoundToLesson
|| !person.IsStudent
|| state.IsWalking
|| state.NodeId is null
|| school.Catalog is null)
{
return false;
}
var location = school.Map?.NodeDef(state.NodeId);
if (location is null || !location.Equals("GymHall", StringComparison.Ordinal))
{
return false;
}
if (!Conflict.HasRival(state.Talk))
{
return false;
}
return Conflict.RollFight(
person.Traits,
school.Catalog,
school.Catalog.BehaviorRules,
Seed.Mix(
school.PeopleSeed,
person.Id,
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
Seed.ConflictSalt + 3 + (school.Clock.Time.Minute / 2)));
}
private static bool ShouldStartApology(School school, Person person, ActorState state)
{
if (!person.IsStudent || state.IsWalking || state.NodeId is null || school.Catalog is null)
{
return false;
}
var hasDebtHere = false;
foreach (var debt in school.ApologyDebts.Values)
{
if (!debt.FromId.Equals(person.Id, StringComparison.Ordinal))
{
continue;
}
if (!state.Talk.PersonNodes.TryGetValue(debt.ToId, out var node)
|| !node.Equals(state.NodeId, StringComparison.Ordinal))
{
continue;
}
hasDebtHere = true;
break;
}
if (!hasDebtHere)
{
return false;
}
var chance = Math.Clamp(
Conflict.ApologyChance(person.Traits, school.Catalog),
0f,
1f);
return Conflict.RollStarts(
chance,
Seed.Mix(
school.PeopleSeed,
person.Id,
DateOnly.FromDateTime(school.Clock.Time).DayNumber,
Seed.ConflictSalt + 4));
}
private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId)
{
var occupied = new Dictionary<(string Node, string Thing), int>();
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
{
if (identity.Id.Equals(exceptId, StringComparison.Ordinal)
|| !activity.IsActive
|| presence.NodeId is null
|| activity.Thing is null)
{
return;
}
var key = (presence.NodeId, activity.Thing);
occupied[key] = occupied.GetValueOrDefault(key) + 1;
});
return occupied;
}
private static void Move(School school, float minutes)
{
if (minutes <= 0 || school.Walks is null)
{
return;
}
var walks = school.Walks;
var world = school.World;
var arrived = new List<string>();
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
return;
}
var travelling = presence.Path.Length > 0 || presence.RemainingMinutes > 0;
var remaining = presence.RemainingMinutes - minutes;
var node = presence.NodeId!;
var path = presence.Path;
var index = 0;
while (remaining <= 0 && index < path.Length)
{
node = path[index];
index++;
remaining += walks.TravelMinutes(node);
}
if (remaining < 0)
{
remaining = 0;
}
var leftover = index >= path.Length ? [] : path[index..];
presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
if (travelling && leftover.Length == 0 && remaining <= 0)
{
arrived.Add(identity.Id);
}
});
foreach (var id in arrived.OrderBy(value => value, StringComparer.Ordinal))
{
school.DecisionQueue.Enqueue(id);
}
}
private static void FinishHome(School school)
{
var yard = school.Walks?.TerritoryId;
if (yard is null)
{
return;
}
var world = school.World;
world.Query(in People, (ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (presence.HeadingHome
&& presence.NodeId is not null
&& presence.Path.Length == 0
&& presence.RemainingMinutes <= 0
&& presence.NodeId.Equals(yard, StringComparison.Ordinal))
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
intent = Intent.None;
}
});
}
private static Intent ParseIntent(PresenceSnapshot row)
{
if (string.IsNullOrWhiteSpace(row.GoalKind)
|| !Enum.TryParse<GoalKind>(row.GoalKind, out var kind)
|| kind == GoalKind.None)
{
return Intent.None;
}
return new Intent(kind, row.GoalId, row.GoalWeight, row.GoalAction);
}
private static TalkPlannerContext BuildTalkContext(
School school,
Person person,
bool boundToLesson,
bool lunchOpen,
DaySlot slot)
{
var opinions = person.Opinions as IReadOnlyDictionary<string, int> ?? new Dictionary<string, int>(StringComparer.Ordinal);
var nodes = SnapshotPersonNodes(school);
var friendPull = !boundToLesson && (slot.Kind == DaySlotKind.Break || lunchOpen);
return new TalkPlannerContext(
person.Id,
opinions,
nodes,
person.ClassId,
TalkCircles.HasPhone(person.Items),
friendPull,
school.Catalog?.BehaviorRules,
person.BullyVictimId);
}
private static Dictionary<string, string> SnapshotPersonNodes(School school)
{
var nodes = new Dictionary<string, string>(StringComparer.Ordinal);
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{
if (presence.IsOnCampus && presence.NodeId is not null)
{
nodes[identity.Id] = presence.NodeId;
}
});
return nodes;
}
private static SchoolClass? ClassOf(School school, Person person)
{
if (person.ClassId is null)
{
return null;
}
return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal));
}
private static IReadOnlyList<Person> OrderedPeople(School school) =>
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
private delegate void PersonAction(
Person person,
PersonIdentity identity,
ref Presence presence,
ref PersonActivity activity,
ref Intent intent);
private static void ForEachPerson(School school, PersonAction action)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (roster.TryGetValue(identity.Id, out var person))
{
action(person, identity, ref presence, ref activity, ref intent);
}
});
}
}
public sealed record PresenceSnapshot(
string PersonId,
string? NodeId,
float RemainingMinutes,
string? DestinationId,
bool HeadingHome,
IReadOnlyList<string> Path,
string? ActionId = null,
string? ActionThing = null,
float ActionRemaining = 0f,
string? GoalKind = null,
string? GoalId = null,
float GoalWeight = 0f,
string? GoalAction = null);
/// <summary>Ids of an active talk circle. Names are resolved by the client, not here.</summary>
public sealed record TalkCirclePresence(string TopicId, IReadOnlyList<string> MemberIds);