Enhance AI and simulation components with presence management and routing capabilities
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s

- Introduced the `HSchool.Ai` project, responsible for routing, day plans, and presence management.
- Updated the `HSchool.Simulation` project to integrate with the new AI functionalities, improving decision-making and presence tracking.
- Added `travelMinutes` to room and territory definitions, ensuring accurate movement calculations within the simulation.
- Enhanced the `School` class to manage presence and implement empty-time skipping functionality.
- Updated documentation to reflect the new AI features and their impact on school simulation.
- Added tests for presence management and routing to ensure robust functionality and reliability.
This commit is contained in:
Leonid Pershin
2026-08-19 16:33:52 +03:00
parent c16c34a83c
commit 4137400621
50 changed files with 1978 additions and 57 deletions
+374
View File
@@ -0,0 +1,374 @@
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, PersonTraits, Presence>();
public static void Apply(School school, double gameMinutes)
{
if (school.Catalog is null || school.Walks is null || school.Roster is null)
{
return;
}
EnsurePlans(school);
EnqueueEvents(school);
EnqueueTimeEvents(school, (float)gameMinutes);
DrainDecisions(school);
Move(school, (float)gameMinutes);
FinishHome(school);
}
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) =>
{
rows.Add(new PresenceSnapshot(
identity.Id,
presence.NodeId,
presence.RemainingMinutes,
presence.DestinationId,
presence.HeadingHome,
presence.Path));
});
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) =>
{
if (!byId.TryGetValue(person.Id, out var row))
{
presence = PlaceByDuty(school, person);
return;
}
if (row.NodeId is null)
{
presence = Presence.OffCampus;
return;
}
presence = new Presence(
row.NodeId,
row.RemainingMinutes,
row.DestinationId,
row.HeadingHome,
row.Path?.ToArray() ?? []);
});
}
public static void PlaceMissingByDuty(School school)
{
ForEachPerson(school, (person, _, ref presence) =>
{
if (!presence.IsOnCampus)
{
presence = PlaceByDuty(school, person);
}
});
}
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 DrainDecisions(School school)
{
var budget = school.MaxDecisionsPerTick;
while (budget > 0 && school.DecisionQueue.Count > 0)
{
var id = school.DecisionQueue.Dequeue();
Decide(school, id);
budget--;
}
}
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 world = school.World;
var found = false;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{
if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
found = true;
presence = NextPresence(school, person, plan, presence);
});
}
private static Presence NextPresence(School school, Person person, DayPlan plan, Presence presence)
{
var now = school.Clock.Time;
var walks = school.Walks!;
if (!presence.IsOnCampus)
{
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);
return dest is null ? Presence.OffCampus : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
}
return Presence.OffCampus;
}
if (plan.WalkHomeAt is { } leave && now >= leave)
{
return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
}
var duty = Duty.RoomAt(
person,
ClassOf(school, person),
school.Timetable,
school.Catalog!,
now,
school.SchoolWeekDays);
if (duty is null)
{
return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
}
if (presence.HeadingHome || !duty.Equals(presence.DestinationId, StringComparison.Ordinal))
{
return PresenceStepper.StartWalk(presence, walks, duty, headingHome: false);
}
return presence;
}
private static void Move(School school, float minutes)
{
if (minutes <= 0 || school.Walks is null)
{
return;
}
var walks = school.Walks;
var world = school.World;
world.Query(in People, (ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
return;
}
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 };
});
}
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) =>
{
if (presence.HeadingHome
&& presence.NodeId is not null
&& presence.Path.Length == 0
&& presence.RemainingMinutes <= 0
&& presence.NodeId.Equals(yard, StringComparison.Ordinal))
{
presence = Presence.OffCampus;
}
});
}
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);
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) =>
{
if (roster.TryGetValue(identity.Id, out var person))
{
action(person, identity, ref presence);
}
});
}
}
public sealed record PresenceSnapshot(
string PersonId,
string? NodeId,
float RemainingMinutes,
string? DestinationId,
bool HeadingHome,
IReadOnlyList<string> Path);