Files
h-school/src/HSchool.Simulation/School.cs
T
Leonid Pershin b135a9caad Enhance protocol and simulation features with activity tracking and behavior definitions
- 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.
2026-08-19 19:49:44 +03:00

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);
}