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();
internal int DecisionBudget { get; set; }
public int PendingDecisionCount => DecisionQueue.Count;
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? 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 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);
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);
}
///
/// 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, presence, actions, 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);
foreach (var id in ActivitySystem.Apply(this, gameMinutes))
{
PresenceSystem.Enqueue(this, id);
}
if (Catalog is not null)
{
var below = PresenceSystem.BelowThreshold(this);
NeedDecay.Apply(World, Catalog, gameMinutes);
PresenceSystem.EnqueueNewlyUrgent(this, below);
PresenceSystem.DrainDecisions(this);
LessonLearningSystem.Apply(this, 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);
}