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 = []; private readonly HashSet _lessonLogOnce = new(StringComparer.Ordinal); private readonly HashSet _lessonMarkOnce = new(StringComparer.Ordinal); private readonly List _worldEvents = []; 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; /// Student and staff dress rules. Pending pair applies on the next work morning. public SchoolDressRules DressRules { get; set; } = new(); /// Student and staff speech-topic rules. Pending ids apply on the next work morning. public SchoolSpeechRules SpeechRules { get; set; } = new(); /// 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; } /// Last slot observed — detects leaving a lesson to finalize absents. internal DaySlot? LastAttendanceSlot { get; set; } internal Dictionary Plans { get; } = new(StringComparer.Ordinal); internal Queue DecisionQueue { get; } = new(); internal int DecisionBudget { get; set; } internal int HeavySystemsInvocations { get; private set; } internal double LastHeavyGameMinutes { get; private set; } private int _impulseCounter; 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 Dictionary TalkCirclesById { get; } = new(StringComparer.Ordinal); internal Dictionary TalkCircleByPerson { get; } = new(StringComparer.Ordinal); internal Dictionary ApologyDebts { get; } = new(StringComparer.Ordinal); /// /// Auto director summons FIFO. Worker thread only — never HTTP. Cleared on morning / skip. /// internal List DirectorSummons { get; } = []; internal int NextSummonOrder { get; set; } /// /// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip. /// internal List ActiveParentMeetings { get; } = []; /// Class ids that already rolled a meeting chance today (pass or fail). internal HashSet ParentMeetingRolledToday { get; } = new(StringComparer.Ordinal); /// /// A finished or interrupted circle wrote opinions onto the roster. /// returns this so the worker persists people.json — same seam as morning dress. /// internal bool RosterTalkDirty { get; set; } internal void ResetDayLog() { _dayLog.Clear(); LoggedActivity.Clear(); _lessonLogOnce.Clear(); _lessonMarkOnce.Clear(); LastAttendanceSlot = null; } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); /// /// Lesson-quality rows fire every tick the condition holds. One key per person per day is enough. /// internal bool TryLogLessonOnce(string personId, string type, string subject) { var key = string.Concat(personId, "\0", type, "\0", subject); if (!_lessonLogOnce.Add(key)) { return false; } AppendDayLog(new PersonLogEvent(personId, Clock.Time, type, subject)); return true; } /// /// One lesson mark per person per calendar day and period. Survives teacher arriving mid-slot. /// internal bool TryClaimLessonMark(string personId, DateTime day, int period) { var key = string.Concat(personId, "\0", day.ToString("yyyy-MM-dd"), "\0", period.ToString()); return _lessonMarkOnce.Add(key); } 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; LastAttendanceSlot = 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); /// /// Live circle for the presence frame and the person card. Null when this person is not talking. /// Member ids include self and are sorted; names stay off the wire. /// public TalkCirclePresence? TalkCircleOf(string personId) { if (!TalkCircleByPerson.TryGetValue(personId, out var circleId) || !TalkCirclesById.TryGetValue(circleId, out var circle)) { return null; } var members = circle.Members.ToArray(); Array.Sort(members, StringComparer.Ordinal); return new TalkCirclePresence(circle.TopicId, members); } 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); TalkCircleSystem.AbandonAll(this); DirectorSummonSystem.ClearAll(this); ParentMeetingSystem.ClearAll(this); 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); RecordWorldEvents(before, next.Value); 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; } /// /// Writes roster fields that do not change who is in the World (class-teacher slots). No /// respawn and no timetable dirty — unlike . /// public void ApplyRosterData(Roster roster) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(roster); Roster = roster; } 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, apparel wear, then health conditions. /// 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(); // Hung summons do not survive the work morning — same clear as skip empty. DirectorSummonSystem.ClearAll(this); ParentMeetingSystem.ClearAll(this); } peopleChanged = TryYearlyIntake(before, Clock.Time); peopleChanged |= TryApplicantRefresh(); if (peopleChanged) { PlanDay = null; LastDecisionSlot = null; } SyncWeather(force: false); _impulseCounter++; var stride = ClockSpeed.HeavySystemsStride(Clock.SpeedIndex); if (_impulseCounter % stride == 0) { var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes); peopleChanged |= ApplyHeavySystems(heavyMinutes); } RecordWorldEvents(before, Clock.Time); } else { SyncWeather(force: false); } return peopleChanged; } /// Facts raised since the last drain. The worker maps them to notices; simulation has no UI. public IReadOnlyList DrainWorldEvents() { if (_worldEvents.Count == 0) { return []; } var copy = _worldEvents.ToArray(); _worldEvents.Clear(); return copy; } /// Queue a fact for the worker's notice board. Simulation never builds UI itself. internal void RaiseWorldEvent(in WorldEvent fact) => _worldEvents.Add(fact); /// /// Presence wire tag for auto director summons. Values match DirectorSummons.Presence* /// and . /// public byte DirectorSummonPresencePhase(string personId) => DirectorSummonSystem.ResolvePresencePhase(this, personId); /// ActionDef id for the person card when summoned but the live activity is idle. public string? DirectorSummonPresenceActionId(string personId) => DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId)); /// /// Card label while walking to / sitting in a parent meeting without a live activity yet. /// public string? ParentMeetingPresenceActionId(string personId) { if (!ParentMeetingSystem.IsInMeeting(this, personId)) { return null; } var session = ActiveParentMeetings.FirstOrDefault(row => row.TeacherId.Equals(personId, StringComparison.Ordinal) || row.AttendeeIds.Contains(personId)); if (session is null) { return null; } return session.InProgress && session.TeacherId.Equals(personId, StringComparison.Ordinal) ? HSchool.Ai.ParentMeetings.MeetingAction : HSchool.Ai.ParentMeetings.GoingAction; } private void RecordWorldEvents(DateTime before, DateTime after) { _worldEvents.AddRange(EventSystem.Detect(this, before, after)); } private bool ApplyHeavySystems(double gameMinutes) { HeavySystemsInvocations++; LastHeavyGameMinutes = gameMinutes; var peopleChanged = false; PresenceSystem.Apply(this, gameMinutes); foreach (var id in ActivitySystem.Apply(this, gameMinutes)) { PresenceSystem.Enqueue(this, id); } var talked = TalkCircleSystem.Apply(this, gameMinutes); foreach (var id in talked) { PresenceSystem.Enqueue(this, id); } DirectorSummonSystem.Apply(this); ParentMeetingSystem.Apply(this); PersonDayLog.Sync(this); if (Catalog is not null) { var below = PresenceSystem.BelowThreshold(this); NeedDecay.Apply(World, Catalog, gameMinutes); WarmthDecay.Apply(this, gameMinutes); PresenceSystem.EnqueueNewlyUrgent(this, below); PresenceSystem.DrainDecisions(this); LessonLearningSystem.Apply(this, gameMinutes); AttendanceSystem.Apply(this); peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes)); peopleChanged |= AffinitySystem.Apply(this, talked); } peopleChanged |= HealthConditionSystem.Apply(this, gameMinutes); peopleChanged |= RosterTalkDirty; RosterTalkDirty = 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); CommitWeather(EvaluateWeather(), force); } /// /// Test hook: the vanilla diurnal never crosses outerBelowC during a school day, so a /// mid-day frost has to be injected to prove jackets walk to the locker room instead of /// appearing on the body. /// internal void ForceWeather(OutdoorWeather weather) => CommitWeather(weather, force: true); private void CommitWeather(OutdoorWeather next, bool force) { if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation) { var before = Weather; Weather = next; if (Roster is not null && Catalog is not null) { ApparelPresence.EnqueueOnWeatherChange(this, before, 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); } internal readonly record struct ApologyDebt(string FromId, string ToId, int Baseline, int Lost);