using Arch.Core; using HSchool.Ai; using HSchool.Content; using HSchool.People; using HSchool.Schedule; namespace HSchool.Simulation; /// /// 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. /// public sealed class School : IDisposable { /// Longest name a school may carry, in characters. 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(); } /// A brand-new school: calendar running at the start date, empty world. public static School Create( int id, string name, DateTime startDate, DefCatalog? catalog = null, MapLayout? map = null) => new(id, name, startDate, catalog, map); /// Rebuilds a school from a save. Time, pause and speed come from disk, not defaults. 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; } /// Frozen at create/load. Null only in clock-only unit tests. public DefCatalog? Catalog { get; } /// The school's map instance. Null only in clock-only unit tests. public MapLayout? Map { get; } /// The Arch world backing this school. Only this school's worker thread may touch it. public World World { get; } /// Composition snapshot. Null in clock-only tests or before . public Roster? Roster { get; private set; } /// People looking for work. Not in the roster and not in the World until hired. public ApplicantPool? Applicants { get; private set; } public int PeopleSeed { get; private set; } /// Name pack used to generate this school's people. Needed again on 1 September. public string? NameSetId { get; private set; } /// Last built table. Null until the worker installs people. public Timetable? Timetable { get; private set; } /// True after yearly intake until the worker rebuilds around remaining locks. public bool TimetableDirty { get; private set; } /// Walk matrix for this map. Null in clock-only tests. 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 Plans { get; } = new(StringComparer.Ordinal); internal Queue DecisionQueue { get; } = new(); /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// 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 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 CapturePresence() => PresenceSystem.Capture(this); public void RestorePresence(IReadOnlyList? 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); } /// /// 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. /// 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); } } /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay. /// when the roster or the applicant pool changed this step. 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); 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 SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged) { public bool Succeeded => Error == SkipEmptyError.None; public static SkipEmptyResult Fail(SkipEmptyError error) => new(error, null, false); }