Enhance AI and simulation components with presence management and routing capabilities
- 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user