Adds a weather-drop walk test so a jacket is not teleported onto the body; PE change regressions already red on main stay open. Co-authored-by: Cursor <cursoragent@cursor.com>
543 lines
19 KiB
C#
543 lines
19 KiB
C#
using Arch.Core;
|
|
using HSchool.Ai;
|
|
using HSchool.Content;
|
|
using HSchool.People;
|
|
using HSchool.Schedule;
|
|
|
|
namespace HSchool.Simulation;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class School : IDisposable
|
|
{
|
|
/// <summary>Longest name a school may carry, in characters.</summary>
|
|
public const int MaxNameLength = 40;
|
|
|
|
private bool _disposed;
|
|
private readonly List<PersonLogEvent> _dayLog = [];
|
|
private readonly HashSet<string> _lessonLogOnce = new(StringComparer.Ordinal);
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
|
|
public static School Create(
|
|
int id,
|
|
string name,
|
|
DateTime startDate,
|
|
DefCatalog? catalog = null,
|
|
MapLayout? map = null) =>
|
|
new(id, name, startDate, catalog, map);
|
|
|
|
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
|
|
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; }
|
|
|
|
/// <summary>Frozen at create/load. Null only in clock-only unit tests.</summary>
|
|
public DefCatalog? Catalog { get; }
|
|
|
|
/// <summary>The school's map instance. Null only in clock-only unit tests.</summary>
|
|
public MapLayout? Map { get; }
|
|
|
|
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
|
public World World { get; }
|
|
|
|
/// <summary>Composition snapshot. Null in clock-only tests or before <see cref="InstallPeople"/>.</summary>
|
|
public Roster? Roster { get; private set; }
|
|
|
|
/// <summary>People looking for work. Not in the roster and not in the World until hired.</summary>
|
|
public ApplicantPool? Applicants { get; private set; }
|
|
|
|
public int PeopleSeed { get; private set; }
|
|
|
|
/// <summary>Country used to generate this school's people. Needed again on 1 September.</summary>
|
|
public string? CountryId { get; private set; }
|
|
|
|
/// <summary>Climate preset rolled at birth. Weather reads it; it cannot change on a live school.</summary>
|
|
public string? ClimatePresetId { get; private set; }
|
|
|
|
/// <summary>Street temperature and precipitation last committed for the clock and warmth.</summary>
|
|
public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None;
|
|
|
|
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
|
|
public SchoolDressRules DressRules { get; set; } = new();
|
|
|
|
/// <summary>Student and staff speech-topic rules. Pending ids apply on the next work morning.</summary>
|
|
public SchoolSpeechRules SpeechRules { get; set; } = new();
|
|
|
|
/// <summary>Skill everyone generated for this school speaks natively.</summary>
|
|
public string? NativeLanguage { get; private set; }
|
|
|
|
/// <summary>Last built table. Null until the worker installs people.</summary>
|
|
public Timetable? Timetable { get; private set; }
|
|
|
|
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
|
|
public bool TimetableDirty { get; private set; }
|
|
|
|
/// <summary>Walk matrix for this map. Null in clock-only tests.</summary>
|
|
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<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
|
|
|
|
internal Queue<string> 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;
|
|
|
|
/// <summary>
|
|
/// Today's history for the person card. Cleared at six in the morning. Not written to disk.
|
|
/// </summary>
|
|
public IReadOnlyList<PersonLogEvent> DayLog => _dayLog;
|
|
|
|
internal Dictionary<string, string?> LoggedActivity { get; } = new(StringComparer.Ordinal);
|
|
|
|
internal Dictionary<string, ActiveTalkCircle> TalkCirclesById { get; } = new(StringComparer.Ordinal);
|
|
|
|
internal Dictionary<string, string> TalkCircleByPerson { get; } = new(StringComparer.Ordinal);
|
|
|
|
internal Dictionary<string, ApologyDebt> ApologyDebts { get; } = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// A finished or interrupted circle wrote opinions onto the roster. <see cref="Tick"/>
|
|
/// returns this so the worker persists <c>people.json</c> — same seam as morning dress.
|
|
/// </summary>
|
|
internal bool RosterTalkDirty { get; set; }
|
|
|
|
internal void ResetDayLog()
|
|
{
|
|
_dayLog.Clear();
|
|
LoggedActivity.Clear();
|
|
_lessonLogOnce.Clear();
|
|
}
|
|
|
|
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
|
|
|
|
/// <summary>
|
|
/// Lesson-quality rows fire every tick the condition holds. One key per person per day is enough.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
public void QueueDecision(string personId)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
|
|
PresenceSystem.Enqueue(this, personId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
|
/// </summary>
|
|
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<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<PresenceSnapshot>? 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);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, then apparel wear.</summary>
|
|
/// <returns><see langword="true"/> when the roster, applicant pool or wardrobe changed this step.</returns>
|
|
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;
|
|
}
|
|
|
|
SyncWeather(force: false);
|
|
|
|
_impulseCounter++;
|
|
var stride = ClockSpeed.HeavySystemsStride(Clock.SpeedIndex);
|
|
if (_impulseCounter % stride == 0)
|
|
{
|
|
var heavyMinutes = ClockSpeed.HeavyGameMinutes(Clock.SpeedIndex, gameMinutes);
|
|
peopleChanged |= ApplyHeavySystems(heavyMinutes);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SyncWeather(force: false);
|
|
}
|
|
|
|
return peopleChanged;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
|
peopleChanged |= AffinitySystem.Apply(this, talked);
|
|
}
|
|
|
|
peopleChanged |= RosterTalkDirty;
|
|
RosterTalkDirty = false;
|
|
return peopleChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void SyncWeather(bool force)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
CommitWeather(EvaluateWeather(), force);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test hook: the vanilla diurnal never crosses <c>outerBelowC</c> 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.
|
|
/// </summary>
|
|
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);
|