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; }
/// Calendar day of the last DiseaseDef onset pass (one roll per person per day).
internal int LastDiseaseDay { get; set; } = int.MinValue;
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; }
///
/// 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;
LastDiseaseDay = int.MinValue;
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);
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);
}
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));
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);
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);