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:
@@ -50,6 +50,17 @@ public sealed class GameClock
|
||||
|
||||
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
|
||||
|
||||
/// <summary>Empty-time skip. Not a tick — the calendar jumps to an instant already known to be legal.</summary>
|
||||
public void JumpTo(DateTime time)
|
||||
{
|
||||
if (!IsValidStartDate(time))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(time), time, "Jump target is outside the supported range.");
|
||||
}
|
||||
|
||||
Time = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
|
||||
/// base rate and the current speed. Does nothing while paused.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||
|
||||
@@ -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);
|
||||
@@ -1,5 +1,6 @@
|
||||
using Arch.Core;
|
||||
using HSchool.People;
|
||||
using HSchool.Ai;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
@@ -36,7 +37,8 @@ public static class RosterSpawner
|
||||
person.IsParent,
|
||||
person.ClassId,
|
||||
person.Position,
|
||||
person.WorkplaceRoomId));
|
||||
person.WorkplaceRoomId),
|
||||
Presence.OffCampus);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed class SimulationOptions
|
||||
/// </summary>
|
||||
public float MonthlyPayrollCap { get; set; } = 100_000f;
|
||||
|
||||
/// <summary>
|
||||
/// How many people may change destination in one tick. Overflow waits for the next tick
|
||||
/// instead of being dropped — a queue, not a cutoff.
|
||||
/// </summary>
|
||||
public int MaxDecisionsPerTick { get; set; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user