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
+125
View File
@@ -0,0 +1,125 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Ai;
/// <summary>When this person appears at the yard and when they start walking home today.</summary>
public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime? WalkHomeAt, string? FirstRoom)
{
public bool Comes => AppearAt is not null;
}
public static class DayPlans
{
private const int ExtraRollMax = 7;
public static DayPlan Build(
DefCatalog catalog,
WalkGraph walks,
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
DateTime time,
int weekDays,
int schoolSeed)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(walks);
ArgumentNullException.ThrowIfNull(person);
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
var day = DateOnly.FromDateTime(utc);
if (!Duty.ComesToday(person, timetable, catalog, utc, weekDays) || catalog.DayFrame is null)
{
return new DayPlan(day, null, null, null);
}
var weekday = SchoolDay.WeekdayIndex(utc);
var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday);
if (firstRoom is null)
{
return new DayPlan(day, null, null, null);
}
var firstStart = FirstStart(person, schoolClass, timetable, catalog, weekday);
var lastEnd = LastEnd(person, schoolClass, timetable, catalog, weekday);
var travel = walks.Minutes(walks.TerritoryId, firstRoom);
if (float.IsInfinity(travel))
{
travel = 0f;
}
var slack = SlackMinutes(catalog, person, schoolSeed, day);
var appear = DateTime.SpecifyKind(utc.Date.Add(firstStart.ToTimeSpan()).AddMinutes(-(travel + slack)), DateTimeKind.Utc);
var walkHome = DateTime.SpecifyKind(utc.Date.Add(lastEnd.ToTimeSpan()), DateTimeKind.Utc);
return new DayPlan(day, appear, walkHome, firstRoom);
}
private static string? FirstRoom(
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
DefCatalog catalog,
int weekday)
{
if (Duty.IsOtherStaff(person))
{
return person.WorkplaceRoomId;
}
var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
return lessons.Count > 0 ? lessons[0].RoomId : schoolClass?.RoomId;
}
private static TimeOnly FirstStart(
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
DefCatalog catalog,
int weekday)
{
var frame = catalog.DayFrame!;
if (Duty.IsOtherStaff(person))
{
return SchoolDay.PeriodStart(frame, 1);
}
var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
return lessons.Count > 0 ? SchoolDay.PeriodStart(frame, lessons[0].Period) : SchoolDay.PeriodStart(frame, 1);
}
private static TimeOnly LastEnd(
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
DefCatalog catalog,
int weekday)
{
var frame = catalog.DayFrame!;
if (Duty.IsOtherStaff(person))
{
return SchoolDay.PeriodEnd(frame, frame.LessonCount);
}
var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
return lessons.Count > 0
? SchoolDay.PeriodEnd(frame, lessons[^1].Period)
: SchoolDay.PeriodEnd(frame, frame.LessonCount);
}
private static int SlackMinutes(DefCatalog catalog, Person person, int schoolSeed, DateOnly day)
{
var rng = new Random(Seed.Mix(schoolSeed, person.Id, day.DayNumber, Seed.CommuteSalt));
var extra = rng.Next(0, ExtraRollMax);
foreach (var name in person.Traits)
{
if (catalog.Traits.TryGetValue(name, out var trait))
{
extra += trait.CommuteMinutes;
}
}
return extra;
}
}
+157
View File
@@ -0,0 +1,157 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Ai;
/// <summary>
/// Where this person ought to be right now. The timetable and the job, not the walk graph.
/// </summary>
public static class Duty
{
/// <summary>
/// The room of the current obligation, or <see langword="null"/> when they should be off campus.
/// A hole in the class table sends the pupil to their homeroom.
/// </summary>
public static string? RoomAt(
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
DefCatalog catalog,
DateTime time,
int weekDays)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentNullException.ThrowIfNull(catalog);
if (!ComesToday(person, timetable, catalog, time, weekDays))
{
return null;
}
if (IsOtherStaff(person))
{
return person.WorkplaceRoomId;
}
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
if (!SchoolDay.IsWorkday(catalog, utc, weekDays))
{
return null;
}
var slot = SchoolDay.At(catalog, utc, weekDays);
var day = SchoolDay.WeekdayIndex(utc);
var lessons = LessonsToday(person, schoolClass, timetable, day);
if (lessons.Count == 0)
{
return person.WorkplaceRoomId;
}
if (slot.Kind == DaySlotKind.Lesson)
{
var current = lessons.FirstOrDefault(lesson => lesson.Period == slot.Index);
if (current is not null)
{
return current.RoomId;
}
return schoolClass?.RoomId ?? person.WorkplaceRoomId;
}
if (slot.Kind == DaySlotKind.Break)
{
var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault();
return next?.RoomId ?? schoolClass?.RoomId ?? person.WorkplaceRoomId;
}
var frame = catalog.DayFrame;
if (frame is not null
&& SchoolDay.TryParseTime(frame.FirstLesson, out var first)
&& utc.TimeOfDay < first.ToTimeSpan())
{
return lessons[0].RoomId;
}
if (frame is not null
&& SchoolDay.TryLastBell(catalog, out var last)
&& utc.TimeOfDay >= last.ToTimeSpan())
{
return null;
}
return lessons[0].RoomId;
}
public static bool ComesToday(
Person person,
Timetable? timetable,
DefCatalog catalog,
DateTime time,
int weekDays)
{
if (person.IsParent && !person.IsStaff && !person.IsStudent)
{
return false;
}
if (!SchoolDay.IsWorkday(catalog, time, weekDays))
{
return false;
}
if (IsOtherStaff(person))
{
return true;
}
var day = SchoolDay.WeekdayIndex(time);
if (person.IsStudent)
{
return timetable?.Lessons.Any(lesson =>
lesson.ClassId == person.ClassId && lesson.Day == day) == true;
}
if (person.IsStaff)
{
return timetable?.Lessons.Any(lesson =>
lesson.TeacherId == person.Id && lesson.Day == day) == true;
}
return false;
}
public static bool IsOtherStaff(Person person) =>
person.IsStaff && !Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal);
public static IReadOnlyList<LessonPlacement> LessonsToday(
Person person,
SchoolClass? schoolClass,
Timetable? timetable,
int day)
{
if (timetable is null)
{
return [];
}
if (person.IsStudent)
{
var classId = person.ClassId ?? schoolClass?.Id;
return timetable.Lessons
.Where(lesson => lesson.ClassId == classId && lesson.Day == day)
.OrderBy(lesson => lesson.Period)
.ToArray();
}
if (person.IsStaff)
{
return timetable.Lessons
.Where(lesson => lesson.TeacherId == person.Id && lesson.Day == day)
.OrderBy(lesson => lesson.Period)
.ToArray();
}
return [];
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Ai</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.Ai.Tests" />
</ItemGroup>
</Project>
+79
View File
@@ -0,0 +1,79 @@
namespace HSchool.Ai;
/// <summary>
/// One person's place on the graph. <see cref="NodeId"/> is null when they are off campus.
/// There is no "on an edge" state — remaining minutes are spent occupying the current node.
/// </summary>
public readonly record struct Presence(
string? NodeId,
float RemainingMinutes,
string? DestinationId,
bool HeadingHome,
string[] Path)
{
public static Presence OffCampus { get; } = new(null, 0f, null, false, []);
public bool IsOnCampus => NodeId is not null;
}
/// <summary>
/// Hot-path movement in the library so tests can prove travel time without a world. Simulation
/// copies the same loop onto components and does not call this every tick.
/// </summary>
public static class PresenceStepper
{
public static Presence Advance(Presence presence, WalkGraph walks, float minutes)
{
ArgumentNullException.ThrowIfNull(walks);
if (presence.NodeId is null || minutes <= 0)
{
return presence;
}
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..];
return presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
}
public static Presence StartWalk(Presence presence, WalkGraph walks, string destination, bool headingHome)
{
ArgumentNullException.ThrowIfNull(walks);
if (presence.NodeId is null)
{
var path = walks.Path(walks.TerritoryId, destination);
return new Presence(walks.TerritoryId, 0f, destination, headingHome, [.. path]);
}
var hops = walks.Path(presence.NodeId, destination);
return presence with
{
DestinationId = destination,
HeadingHome = headingHome,
Path = [.. hops],
};
}
public static Presence ArriveOffCampus(Presence presence) =>
presence.NodeId is not null
&& presence.HeadingHome
&& presence.Path.Length == 0
&& presence.RemainingMinutes <= 0
&& presence.NodeId.Equals(presence.DestinationId, StringComparison.Ordinal)
? Presence.OffCampus
: presence;
}
+165
View File
@@ -0,0 +1,165 @@
using HSchool.Content;
namespace HSchool.Ai;
/// <summary>
/// Next-hop matrix for a school's walkable graph. Built once when the school loads; querying a
/// path does not search again.
/// </summary>
public sealed class WalkGraph
{
private const float Unreachable = float.PositiveInfinity;
private readonly string[] _nodes;
private readonly Dictionary<string, int> _index;
private readonly float[] _travel;
private readonly float[,] _distance;
private readonly int[,] _next;
private WalkGraph(
string territoryId,
string[] nodes,
Dictionary<string, int> index,
float[] travel,
float[,] distance,
int[,] next)
{
TerritoryId = territoryId;
_nodes = nodes;
_index = index;
_travel = travel;
_distance = distance;
_next = next;
}
public string TerritoryId { get; }
public IReadOnlyList<string> Nodes => _nodes;
public static WalkGraph Build(DefCatalog catalog, MapLayout map)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
if (map.Territory is null)
{
throw new ArgumentException("A walk graph needs a territory node.", nameof(map));
}
var nodes = new List<string> { map.Territory.Id };
foreach (var room in map.Rooms.OrderBy(room => room.Id, StringComparer.Ordinal))
{
nodes.Add(room.Id);
}
var count = nodes.Count;
var index = new Dictionary<string, int>(count, StringComparer.Ordinal);
var travel = new float[count];
for (var i = 0; i < count; i++)
{
var id = nodes[i];
index[id] = i;
travel[i] = TravelOf(catalog, map, id);
}
var distance = new float[count, count];
var next = new int[count, count];
for (var i = 0; i < count; i++)
{
for (var j = 0; j < count; j++)
{
distance[i, j] = i == j ? 0f : Unreachable;
next[i, j] = -1;
}
}
foreach (var link in map.Links)
{
if (!index.TryGetValue(link.A, out var a) || !index.TryGetValue(link.B, out var b))
{
continue;
}
distance[a, b] = travel[b];
next[a, b] = b;
distance[b, a] = travel[a];
next[b, a] = a;
}
for (var k = 0; k < count; k++)
{
for (var i = 0; i < count; i++)
{
for (var j = 0; j < count; j++)
{
var via = distance[i, k] + distance[k, j];
if (via < distance[i, j])
{
distance[i, j] = via;
next[i, j] = next[i, k];
}
}
}
}
return new WalkGraph(map.Territory.Id, [.. nodes], index, travel, distance, next);
}
public float TravelMinutes(string nodeId) =>
_index.TryGetValue(nodeId, out var i) ? _travel[i] : 0f;
/// <summary>Hops after <paramref name="from"/>, including <paramref name="to"/>. Empty when already there.</summary>
public IReadOnlyList<string> Path(string from, string to)
{
if (from.Equals(to, StringComparison.Ordinal))
{
return [];
}
if (!_index.TryGetValue(from, out var i) || !_index.TryGetValue(to, out var j) || _next[i, j] < 0)
{
return [];
}
var hops = new List<string>();
while (i != j)
{
i = _next[i, j];
if (i < 0)
{
return [];
}
hops.Add(_nodes[i]);
}
return hops;
}
public float Minutes(string from, string to)
{
if (!_index.TryGetValue(from, out var i) || !_index.TryGetValue(to, out var j))
{
return Unreachable;
}
return _distance[i, j];
}
private static float TravelOf(DefCatalog catalog, MapLayout map, string id)
{
if (map.Territory is not null && id.Equals(map.Territory.Id, StringComparison.Ordinal))
{
return catalog.Territories.TryGetValue(map.Territory.Def, out var territory)
? territory.TravelMinutes
: 0f;
}
var room = map.Rooms.FirstOrDefault(candidate => candidate.Id.Equals(id, StringComparison.Ordinal));
if (room is not null && catalog.Rooms.TryGetValue(room.Def, out var def))
{
return def.TravelMinutes;
}
return 0f;
}
}