Adds outfit evaluation, morning home dressing, locker-room ChangeClothes with day-log events, PE/weather AI goals, dress-rules API, and tests.
571 lines
18 KiB
C#
571 lines
18 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.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!;
|
|
if (!presence.IsOnCampus)
|
|
{
|
|
intent = Intent.None;
|
|
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);
|
|
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)
|
|
{
|
|
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 (duty is null)
|
|
{
|
|
activity = PersonActivity.Idle;
|
|
intent = Intent.None;
|
|
presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
|
|
return null;
|
|
}
|
|
|
|
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);
|
|
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 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);
|
|
var decision = DecisionPlanner.Decide(
|
|
school.Catalog!,
|
|
school.Map!,
|
|
walks,
|
|
state,
|
|
(node, thing) => occupied.GetValueOrDefault((node, thing)));
|
|
|
|
if (decision.WalkTo is not null
|
|
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
|
&& activity.IsActive)
|
|
{
|
|
activity = PersonActivity.Idle;
|
|
}
|
|
|
|
intent = decision.Intent;
|
|
if (decision.WalkTo is not null)
|
|
{
|
|
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
|
|
}
|
|
|
|
return decision.StartAction is not null && !activity.IsActive ? decision.StartAction : null;
|
|
}
|
|
|
|
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 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);
|