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; private readonly List _dayLog = []; 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; } /// Country used to generate this school's people. Needed again on 1 September. public string? CountryId { get; private set; } /// Climate preset rolled at birth. Weather reads it; it cannot change on a live school. public string? ClimatePresetId { get; private set; } /// Street temperature and precipitation last committed for the clock and warmth. public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None; /// Skill everyone generated for this school speaks natively. public string? NativeLanguage { 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(); internal int DecisionBudget { get; set; } public int PendingDecisionCount => DecisionQueue.Count; /// /// Today's history for the person card. Cleared at six in the morning. Not written to disk. /// public IReadOnlyList DayLog => _dayLog; internal Dictionary LoggedActivity { get; } = new(StringComparer.Ordinal); internal void ResetDayLog() { _dayLog.Clear(); LoggedActivity.Clear(); } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(personId); PresenceSystem.Enqueue(this, personId); } /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// public void InstallPeople(Roster roster, int seed, string? countryId = null, ApplicantPool? applicants = null, string? nativeLanguage = null, string? climatePresetId = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(roster); Roster = roster; PeopleSeed = seed; CountryId = countryId; ClimatePresetId = climatePresetId; NativeLanguage = nativeLanguage; Applicants = applicants; RosterSpawner.Spawn(World, roster, Catalog); PlanDay = null; LastDecisionSlot = null; Plans.Clear(); DecisionQueue.Clear(); LoggedActivity.Clear(); SyncWeather(force: true); } public bool TryStartAction(string personId, string actionId) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(personId); ArgumentException.ThrowIfNullOrWhiteSpace(actionId); var started = ActivitySystem.TryStart(this, personId, actionId); if (started) { PersonDayLog.Sync(this); } return started; } 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 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); ResetDayLog(); var peopleChanged = TryYearlyIntake(before, next.Value); peopleChanged |= TryApplicantRefresh(); PlanDay = null; LastDecisionSlot = null; NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes); peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before); SyncWeather(force: true); 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, Catalog); PresenceSystem.Restore(this, snapshot); TimetableDirty = true; } public void SetTimetable(Timetable timetable) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(timetable); Timetable = timetable; TimetableDirty = false; // Duty rooms and AppearAt come from this table. EnsurePlans only rebuilds when the day // or the roster size changes, so a pin or an assign would otherwise keep yesterday's // empty-staff plans and leave the campus empty. PlanDay = null; 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, presence, actions, need decay, then apparel wear. /// when the roster, applicant pool or wardrobe 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) { if (PersonDayLog.CrossedDayStart(before, Clock.Time)) { ResetDayLog(); } peopleChanged = TryYearlyIntake(before, Clock.Time); peopleChanged |= TryApplicantRefresh(); if (peopleChanged) { PlanDay = null; LastDecisionSlot = null; } PresenceSystem.Apply(this, gameMinutes); foreach (var id in ActivitySystem.Apply(this, gameMinutes)) { PresenceSystem.Enqueue(this, id); } PersonDayLog.Sync(this); if (Catalog is not null) { var below = PresenceSystem.BelowThreshold(this); SyncWeather(force: false); NeedDecay.Apply(World, Catalog, gameMinutes); WarmthDecay.Apply(this, gameMinutes); PresenceSystem.EnqueueNewlyUrgent(this, below); PresenceSystem.DrainDecisions(this); LessonLearningSystem.Apply(this, gameMinutes); peopleChanged |= ApparelWear.Apply(this, gameMinutes, before); } } else { SyncWeather(force: false); } return peopleChanged; } /// /// Recomputes the street from the preset, seed and current time. Commits when the clock /// tenths or precipitation change, so warmth does not jitter every tick. A skip must force /// the morning sample — yesterday's evening must not stick. /// public void SyncWeather(bool force) { ObjectDisposedException.ThrowIf(_disposed, this); var next = EvaluateWeather(); if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation) { Weather = next; } } private OutdoorWeather EvaluateWeather() { if (Catalog is null || ClimatePresetId is null || !Catalog.ClimatePresets.TryGetValue(ClimatePresetId, out var preset) || preset.Abstract) { return OutdoorWeather.None; } return WeatherSampler.Sample(preset, PeopleSeed, Clock.Time); } private bool TryYearlyIntake(DateTime before, DateTime after) { if (Roster is null || Catalog is null || CountryId is null) { return false; } var changed = false; foreach (var date in YearlyIntake.DatesBetween(before, after)) { Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, CountryId, date, NativeLanguage); if (Map is not null) { Roster = LockerAssigner.Apply(Catalog, Map, Roster); } changed = true; } if (changed) { var snapshot = PresenceSystem.Capture(this); RosterSpawner.Replace(World, Roster, Catalog); PresenceSystem.Restore(this, snapshot); TimetableDirty = true; } return changed; } private bool TryApplicantRefresh() { if (Applicants is null || Roster is null || Catalog is null || CountryId is null || Catalog.StaffingRules is null) { return false; } var next = Applicants.Advance(Catalog, Roster, PeopleSeed, CountryId, Clock.Time, NativeLanguage); 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); }