- Updated protocol documentation to include new `activity` and `activityLabel` fields in the person card response, reflecting real-time activity status. - Introduced `BehaviorDef` to define behavior rules, including need thresholds and lesson skill gains, enhancing AI decision-making. - Revised the `DefCatalog` to incorporate behavior definitions and updated validation logic to ensure proper behavior handling. - Enhanced the simulation to manage presence and activity states, allowing for more dynamic interactions within the school environment. - Updated tests to validate the new activity tracking and behavior functionalities, ensuring robust performance and reliability. - Improved localization strings to support new activity and behavior features, enhancing user experience.
331 lines
11 KiB
C#
331 lines
11 KiB
C#
using Arch.Core;
|
|
using HSchool.Ai;
|
|
using HSchool.Content;
|
|
using HSchool.People;
|
|
using HSchool.Schedule;
|
|
|
|
namespace HSchool.Simulation;
|
|
|
|
/// <summary>
|
|
/// One save: a name, a calendar, a frozen def catalog, a map instance, the ECS world, and — once
|
|
/// people exist — the roster those entities were built from.
|
|
/// </summary>
|
|
public sealed class School : IDisposable
|
|
{
|
|
/// <summary>Longest name a school may carry, in characters.</summary>
|
|
public const int MaxNameLength = 40;
|
|
|
|
private bool _disposed;
|
|
|
|
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
|
|
{
|
|
Id = id;
|
|
Name = name;
|
|
Clock = new GameClock(startDate);
|
|
Catalog = catalog;
|
|
Map = map;
|
|
World = World.Create();
|
|
}
|
|
|
|
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
|
|
public static School Create(
|
|
int id,
|
|
string name,
|
|
DateTime startDate,
|
|
DefCatalog? catalog = null,
|
|
MapLayout? map = null) =>
|
|
new(id, name, startDate, catalog, map);
|
|
|
|
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
|
|
public static School Load(
|
|
int id,
|
|
string name,
|
|
DateTime time,
|
|
bool running,
|
|
int speedIndex,
|
|
DefCatalog? catalog = null,
|
|
MapLayout? map = null)
|
|
{
|
|
var school = new School(id, name, time, catalog, map);
|
|
school.Clock.IsRunning = running;
|
|
school.Clock.SpeedIndex = speedIndex;
|
|
return school;
|
|
}
|
|
|
|
public int Id { get; }
|
|
|
|
public string Name { get; }
|
|
|
|
public GameClock Clock { get; }
|
|
|
|
/// <summary>Frozen at create/load. Null only in clock-only unit tests.</summary>
|
|
public DefCatalog? Catalog { get; }
|
|
|
|
/// <summary>The school's map instance. Null only in clock-only unit tests.</summary>
|
|
public MapLayout? Map { get; }
|
|
|
|
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
|
public World World { get; }
|
|
|
|
/// <summary>Composition snapshot. Null in clock-only tests or before <see cref="InstallPeople"/>.</summary>
|
|
public Roster? Roster { get; private set; }
|
|
|
|
/// <summary>People looking for work. Not in the roster and not in the World until hired.</summary>
|
|
public ApplicantPool? Applicants { get; private set; }
|
|
|
|
public int PeopleSeed { get; private set; }
|
|
|
|
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
|
public string? NameSetId { get; private set; }
|
|
|
|
/// <summary>Last built table. Null until the worker installs people.</summary>
|
|
public Timetable? Timetable { get; private set; }
|
|
|
|
/// <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>
|
|
public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(roster);
|
|
|
|
Roster = roster;
|
|
PeopleSeed = seed;
|
|
NameSetId = nameSetId;
|
|
Applicants = applicants;
|
|
RosterSpawner.Spawn(World, roster);
|
|
PlanDay = null;
|
|
LastDecisionSlot = null;
|
|
Plans.Clear();
|
|
DecisionQueue.Clear();
|
|
}
|
|
|
|
public bool TryStartAction(string personId, string actionId)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
|
return ActivitySystem.TryStart(this, personId, actionId);
|
|
}
|
|
|
|
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 SkipEmptyPeek PeekSkipEmpty()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (Catalog is null || !IsCampusEmpty() || SchoolDay.InWorkWindow(Catalog, Clock.Time, SchoolWeekDays))
|
|
{
|
|
return SkipEmptyPeek.Refused;
|
|
}
|
|
|
|
var next = SchoolDay.NextWorkMorning(Catalog, Clock.Time, SchoolWeekDays, MaxSkipDays);
|
|
return next is null ? SkipEmptyPeek.Refused : new SkipEmptyPeek(true, next.Value);
|
|
}
|
|
|
|
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;
|
|
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
|
|
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the roster and applicant pool after hire or a subject change. The World is rebuilt
|
|
/// so the people list and card match; needs reset to the roster snapshot, same as yearly intake.
|
|
/// </summary>
|
|
public void ApplyStaffing(Roster roster, ApplicantPool applicants)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(roster);
|
|
ArgumentNullException.ThrowIfNull(applicants);
|
|
|
|
Roster = roster;
|
|
Applicants = applicants;
|
|
var snapshot = PresenceSystem.Capture(this);
|
|
RosterSpawner.Replace(World, roster);
|
|
PresenceSystem.Restore(this, snapshot);
|
|
TimetableDirty = true;
|
|
}
|
|
|
|
public void SetTimetable(Timetable timetable)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
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, presence, actions, then need decay.</summary>
|
|
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
|
|
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
var before = Clock.Time;
|
|
var gameMinutes = Clock.Advance(deltaTime, gameMinutesPerRealSecond);
|
|
var peopleChanged = false;
|
|
if (gameMinutes > 0)
|
|
{
|
|
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
|
peopleChanged |= TryApplicantRefresh();
|
|
if (peopleChanged)
|
|
{
|
|
PlanDay = null;
|
|
LastDecisionSlot = null;
|
|
}
|
|
|
|
PresenceSystem.Apply(this, gameMinutes);
|
|
ActivitySystem.Apply(this, gameMinutes);
|
|
if (Catalog is not null)
|
|
{
|
|
NeedDecay.Apply(World, Catalog, gameMinutes);
|
|
}
|
|
}
|
|
|
|
return peopleChanged;
|
|
}
|
|
|
|
private bool TryYearlyIntake(DateTime before, DateTime after)
|
|
{
|
|
if (Roster is null || Catalog is null || NameSetId is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var changed = false;
|
|
foreach (var date in YearlyIntake.DatesBetween(before, after))
|
|
{
|
|
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
var snapshot = PresenceSystem.Capture(this);
|
|
RosterSpawner.Replace(World, Roster);
|
|
PresenceSystem.Restore(this, snapshot);
|
|
TimetableDirty = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
private bool TryApplicantRefresh()
|
|
{
|
|
if (Applicants is null || Roster is null || Catalog is null || NameSetId is null || Catalog.StaffingRules is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time);
|
|
if (next.Week == Applicants.Week)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Applicants = next;
|
|
return true;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
|
|
// Fully qualified: the `World` property would otherwise shadow the type.
|
|
Arch.Core.World.Destroy(World);
|
|
}
|
|
}
|
|
|
|
public enum SkipEmptyError
|
|
{
|
|
None,
|
|
PeoplePresent,
|
|
InWorkWindow,
|
|
NoMorning,
|
|
}
|
|
|
|
public readonly record struct SkipEmptyPeek(bool Allowed, DateTime? Time)
|
|
{
|
|
public static SkipEmptyPeek Refused { get; } = new(false, null);
|
|
}
|
|
|
|
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);
|
|
}
|