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;
}
}
+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.
+24 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.People;
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
/// so appending a thirteenth family leaves the first twelve unchanged.
/// </summary>
internal static class Seed
public static class Seed
{
public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2;
@@ -12,6 +12,7 @@ internal static class Seed
public const int SeatShuffleSalt = 4;
public const int HouseholdSalt = 5;
public const int ApplicantSalt = 6;
public const int CommuteSalt = 7;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
@@ -24,6 +25,28 @@ internal static class Seed
return (int)z;
}
/// <summary>A stream that belongs to one person on one calendar day — commute slack, not looks.</summary>
public static int Mix(int schoolSeed, string personId, int dayNumber, int salt)
{
ArgumentNullException.ThrowIfNull(personId);
var z = Mix64((uint)schoolSeed);
z = Mix64(z ^ Stable(personId));
z = Mix64(z ^ (uint)(dayNumber + 1));
z = Mix64(z ^ (uint)(salt + 1));
return (int)z;
}
private static uint Stable(string value)
{
ulong z = 0;
foreach (var character in value)
{
z = Mix64(z ^ character);
}
return (uint)z;
}
private static ulong Mix64(ulong z)
{
z += 0x9E3779B97F4A7C15UL;
+5 -2
View File
@@ -494,7 +494,8 @@ internal sealed class GameLoopService(
isNew: false,
save.ModIds,
save.Map,
save.NameSetId);
save.NameSetId,
save.Presence);
worker.Start();
try
@@ -541,7 +542,8 @@ internal sealed class GameLoopService(
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? map,
string? nameSetId) =>
string? nameSetId,
IReadOnlyList<PresenceSnapshot>? presence = null) =>
new(
id,
name,
@@ -552,6 +554,7 @@ internal sealed class GameLoopService(
modIds,
map,
nameSetId,
presence,
_options,
clients,
metrics,
+3
View File
@@ -27,6 +27,8 @@ internal sealed class SchoolSave
public MapLayout? Map { get; init; }
public string? NameSetId { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -160,6 +162,7 @@ internal sealed class SchoolStore
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
Presence = save.Presence,
});
}
catch (Exception ex)
+6
View File
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly Action<int> _onFailed;
private readonly int _id;
@@ -64,6 +65,7 @@ internal sealed class SchoolWorker
IReadOnlyList<string>? modIds,
MapLayout? savedMap,
string? nameSetId,
IReadOnlyList<PresenceSnapshot>? savedPresence,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -81,6 +83,7 @@ internal sealed class SchoolWorker
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
_savedPresence = savedPresence;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -882,6 +885,8 @@ internal sealed class SchoolWorker
school.InstallPeople(roster, seed, nameSetId, applicants);
InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence);
return generated;
}
@@ -927,6 +932,7 @@ internal sealed class SchoolWorker
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
Presence = school.CapturePresence(),
});
}
catch (Exception ex)
+1
View File
@@ -23,6 +23,7 @@ builder.Services
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.")
.Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
+2 -1
View File
@@ -15,6 +15,7 @@
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 100000,
"SchoolWeekDays": 5
"SchoolWeekDays": 5,
"MaxDecisionsPerTick": 64
}
}
@@ -5,6 +5,7 @@
"seatThing": "StudentDesk",
"defaultSeats": 16,
"works": ["TeachLesson"],
"travelMinutes": 0.5,
},
{
"defName": "Library",
@@ -14,6 +15,7 @@
],
"positions": ["Librarian"],
"works": ["LibraryWork"],
"travelMinutes": 0.5,
},
{
"defName": "ComputerLab",
@@ -23,5 +25,6 @@
{ "key": "computers", "thing": "Computer", "count": 12 },
],
"works": ["TeachLesson"],
"travelMinutes": 0.5,
},
]
@@ -1,5 +1,5 @@
[
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose.
{ "defName": "EntranceHall" },
{ "defName": "Stairwell" },
{ "defName": "EntranceHall", "travelMinutes": 1 },
{ "defName": "Stairwell", "travelMinutes": 1.5 },
]
@@ -1,2 +1,2 @@
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
{ "defName": "Corridor" }
{ "defName": "Corridor", "travelMinutes": 1.5 }
@@ -4,4 +4,5 @@
{ "key": "benches", "thing": "Bench", "count": 4 },
],
"works": ["PELesson"],
"travelMinutes": 0.5,
}
@@ -7,4 +7,5 @@
],
"positions": ["Principal"],
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
"travelMinutes": 0.5,
}
@@ -7,6 +7,7 @@
],
"positions": ["Secretary"],
"works": ["OfficeWork"],
"travelMinutes": 0.5,
},
{
"defName": "TeachersRoom",
@@ -14,6 +15,7 @@
{ "key": "table", "thing": "DiningTable" },
{ "key": "chairs", "thing": "Chair", "count": 6 },
],
"travelMinutes": 0.5,
},
{
"defName": "Cafeteria",
@@ -23,9 +25,11 @@
],
"positions": ["CafeteriaCook"],
"works": ["ServeLunch"],
"travelMinutes": 0.5,
},
{
"defName": "Restroom",
"travelMinutes": 0.5,
},
{
"defName": "MedicalOffice",
@@ -35,11 +39,13 @@
],
"positions": ["Nurse"],
"works": ["MedicalDuty"],
"travelMinutes": 0.5,
},
{
"defName": "ChangingRoom",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
],
"travelMinutes": 0.5,
},
]
@@ -1 +1 @@
{ "defName": "SchoolYard" }
{ "defName": "SchoolYard", "travelMinutes": 3 }
@@ -2,6 +2,7 @@
{
"defName": "Diligent",
"weight": 8,
"commuteMinutes": 4,
"incompatible": ["Lazy", "AbsentMinded"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": 8 },
@@ -47,6 +48,7 @@
"defName": "Lazy",
"weight": 6,
"incompatible": ["Diligent"],
"commuteMinutes": -4,
"skillModifiers": [
{ "skill": "PhysicalEducation", "offset": -8 },
{ "skill": "Mathematics", "offset": -6 },
+11
View File
@@ -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" />
+374
View File
@@ -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);
+3 -1
View File
@@ -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);
}
}
+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);
}
@@ -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 MonFri; six adds Saturday; seven is every day.
/// </summary>