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
+13
View File
@@ -468,6 +468,19 @@ public sealed class CatalogLoader
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
if (!room.Abstract && room.TravelMinutes <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' travelMinutes must be positive.");
}
}
foreach (var territory in catalog.Territories.Values)
{
if (!territory.Abstract && territory.TravelMinutes <= 0)
{
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
}
}
PeopleDefValidator.Validate(catalog);
+8 -1
View File
@@ -77,10 +77,17 @@ public sealed class RoomDef : Def
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
public int DefaultSeats { get; init; }
/// <summary>Game minutes spent occupying this room when walking through it.</summary>
public float TravelMinutes { get; init; }
}
public sealed class BuildingDef : Def;
public sealed class FloorDef : Def;
public sealed class TerritoryDef : Def;
public sealed class TerritoryDef : Def
{
/// <summary>Game minutes spent occupying the yard when walking through it.</summary>
public float TravelMinutes { get; init; }
}
+5
View File
@@ -167,6 +167,11 @@ public sealed class TraitDef : Def
/// Added to the hourly wage ask. Positive means the person wants more at the same skills.
/// </summary>
public float WageAsk { get; init; }
/// <summary>
/// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer.
/// </summary>
public int CommuteMinutes { get; init; }
}
public sealed class StaffingDef : Def
+107
View File
@@ -95,6 +95,113 @@ public static class SchoolDay
return stamp >= start || stamp <= end;
}
/// <summary>The work window opens at six — the same hour a new school starts.</summary>
public static TimeOnly DayStart { get; } = new(6, 0);
public static bool IsWorkday(DefCatalog catalog, DateTime time, int weekDays)
{
ArgumentNullException.ThrowIfNull(catalog);
EnsureWeekDays(weekDays);
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
return IsWeekday(utc, weekDays) && !IsHoliday(catalog, utc);
}
/// <summary>
/// Six in the morning until the last bell of the day frame — not the actual timetable.
/// A school with no teachers still has a work window on a workday.
/// </summary>
public static bool InWorkWindow(DefCatalog catalog, DateTime time, int weekDays)
{
if (!IsWorkday(catalog, time, weekDays) || !TryLastBell(catalog, out var lastBell))
{
return false;
}
var clock = DateTime.SpecifyKind(time, DateTimeKind.Utc).TimeOfDay;
return clock >= DayStart.ToTimeSpan() && clock < lastBell.ToTimeSpan();
}
public static bool TryLastBell(DefCatalog catalog, out TimeOnly lastBell)
{
lastBell = default;
var frame = catalog.DayFrame;
if (frame is null || !TryParseTime(frame.FirstLesson, out _))
{
return false;
}
lastBell = PeriodEnd(frame, frame.LessonCount);
return true;
}
public static TimeOnly PeriodStart(DayFrameDef frame, int period)
{
ArgumentNullException.ThrowIfNull(frame);
if (period < 1 || period > frame.LessonCount)
{
throw new ArgumentOutOfRangeException(nameof(period), period, "Period is not on the day frame.");
}
if (!TryParseTime(frame.FirstLesson, out var first))
{
throw new ArgumentException($"Day frame firstLesson '{frame.FirstLesson}' is not a time.", nameof(frame));
}
var cursor = first.ToTimeSpan();
var lesson = TimeSpan.FromMinutes(frame.LessonMinutes);
for (var i = 1; i < period; i++)
{
cursor += lesson;
cursor += TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
}
return TimeOnly.FromTimeSpan(cursor);
}
public static TimeOnly PeriodEnd(DayFrameDef frame, int period) =>
PeriodStart(frame, period).AddMinutes(frame.LessonMinutes);
/// <summary>
/// The next 6:00 of a workday that is still ahead. Night lands on the same morning;
/// evening, weekends and holidays walk forward. <see langword="null"/> when none exists
/// within <paramref name="maxDays"/>.
/// </summary>
public static DateTime? NextWorkMorning(DefCatalog catalog, DateTime time, int weekDays, int maxDays = 400)
{
ArgumentNullException.ThrowIfNull(catalog);
EnsureWeekDays(weekDays);
if (maxDays < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxDays), maxDays, "Search must look at least one day ahead.");
}
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
var day = utc.Date;
if (utc.TimeOfDay < DayStart.ToTimeSpan() && IsWorkday(catalog, day, weekDays))
{
return DateTime.SpecifyKind(day.Add(DayStart.ToTimeSpan()), DateTimeKind.Utc);
}
for (var i = 1; i <= maxDays; i++)
{
var candidate = day.AddDays(i);
if (IsWorkday(catalog, candidate, weekDays))
{
return DateTime.SpecifyKind(candidate.Add(DayStart.ToTimeSpan()), DateTimeKind.Utc);
}
}
return null;
}
private static void EnsureWeekDays(int weekDays)
{
if (weekDays is < 5 or > 7)
{
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 57 days.");
}
}
private static bool IsWeekday(DateTime time, int weekDays)
{
// Monday = 0 … Sunday = 6. A 5-day week is MonFri; 6 adds Saturday; 7 is every day.