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
+104
View File
@@ -1,4 +1,5 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
@@ -83,6 +84,23 @@ public sealed class School : IDisposable
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
public bool TimetableDirty { get; private set; }
/// <summary>Walk matrix for this map. Null in clock-only tests.</summary>
internal WalkGraph? Walks { get; private set; }
internal int SchoolWeekDays { get; private set; } = 5;
internal int MaxDecisionsPerTick { get; private set; } = 64;
internal int MaxSkipDays { get; private set; } = 400;
internal DateOnly? PlanDay { get; set; }
internal DaySlot? LastDecisionSlot { get; set; }
internal Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
internal Queue<string> DecisionQueue { get; } = new();
/// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary>
@@ -96,6 +114,61 @@ public sealed class School : IDisposable
NameSetId = nameSetId;
Applicants = applicants;
RosterSpawner.Spawn(World, roster);
PlanDay = null;
LastDecisionSlot = null;
Plans.Clear();
DecisionQueue.Clear();
}
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
{
ObjectDisposedException.ThrowIf(_disposed, this);
SchoolWeekDays = weekDays;
MaxDecisionsPerTick = maxDecisionsPerTick;
MaxSkipDays = maxSkipDays;
if (Catalog is not null && Map is not null)
{
Walks = WalkGraph.Build(Catalog, Map);
}
}
public IReadOnlyList<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this);
public void RestorePresence(IReadOnlyList<PresenceSnapshot>? saved) => PresenceSystem.Restore(this, saved);
public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
public SkipEmptyResult TrySkipEmpty()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Catalog is null)
{
return SkipEmptyResult.Fail(SkipEmptyError.NoMorning);
}
if (!IsCampusEmpty())
{
return SkipEmptyResult.Fail(SkipEmptyError.PeoplePresent);
}
if (SchoolDay.InWorkWindow(Catalog, Clock.Time, SchoolWeekDays))
{
return SkipEmptyResult.Fail(SkipEmptyError.InWorkWindow);
}
var next = SchoolDay.NextWorkMorning(Catalog, Clock.Time, SchoolWeekDays, MaxSkipDays);
if (next is null)
{
return SkipEmptyResult.Fail(SkipEmptyError.NoMorning);
}
var before = Clock.Time;
Clock.JumpTo(next.Value);
var peopleChanged = TryYearlyIntake(before, next.Value);
peopleChanged |= TryApplicantRefresh();
PlanDay = null;
LastDecisionSlot = null;
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
/// <summary>
@@ -110,7 +183,9 @@ public sealed class School : IDisposable
Roster = roster;
Applicants = applicants;
var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, roster);
PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -120,6 +195,11 @@ public sealed class School : IDisposable
ArgumentNullException.ThrowIfNull(timetable);
Timetable = timetable;
TimetableDirty = false;
LastDecisionSlot = null;
foreach (var id in Roster?.People.Select(person => person.Id) ?? [])
{
DecisionQueue.Enqueue(id);
}
}
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
@@ -135,6 +215,13 @@ public sealed class School : IDisposable
{
peopleChanged = TryYearlyIntake(before, Clock.Time);
peopleChanged |= TryApplicantRefresh();
if (peopleChanged)
{
PlanDay = null;
LastDecisionSlot = null;
}
PresenceSystem.Apply(this, gameMinutes);
if (Catalog is not null)
{
NeedDecay.Apply(World, Catalog, gameMinutes);
@@ -160,7 +247,9 @@ public sealed class School : IDisposable
if (changed)
{
var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, Roster);
PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -197,3 +286,18 @@ public sealed class School : IDisposable
Arch.Core.World.Destroy(World);
}
}
public enum SkipEmptyError
{
None,
PeoplePresent,
InWorkWindow,
NoMorning,
}
public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged)
{
public bool Succeeded => Error == SkipEmptyError.None;
public static SkipEmptyResult Fail(SkipEmptyError error) => new(error, null, false);
}