1073 lines
35 KiB
C#
1073 lines
35 KiB
C#
using System.Diagnostics;
|
|
using System.Threading.Channels;
|
|
using HSchool.Content;
|
|
using HSchool.People;
|
|
using HSchool.Protocol;
|
|
using HSchool.Schedule;
|
|
using HSchool.Server.Api;
|
|
using HSchool.Server.Net;
|
|
using HSchool.Simulation;
|
|
|
|
namespace HSchool.Server.Game;
|
|
|
|
/// <summary>
|
|
/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's
|
|
/// frozen catalog, that school's save file. Awaits are resolved with <c>GetResult</c> so
|
|
/// <see cref="School.Tick"/> stays on this thread instead of hopping back onto the pool.
|
|
/// </summary>
|
|
internal sealed class SchoolWorker
|
|
{
|
|
private const int MaxCatchUpSteps = 5;
|
|
|
|
private readonly SimulationOptions _options;
|
|
private readonly ClientRegistry _clients;
|
|
private readonly GameMetrics _metrics;
|
|
private readonly SchoolStore _store;
|
|
private readonly ModContent _mods;
|
|
private readonly ILogger _logger;
|
|
private readonly Channel<WorkerCommand> _mailbox = Channel.CreateUnbounded<WorkerCommand>(
|
|
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
|
private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly CancellationTokenSource _stopping = new();
|
|
private readonly bool _isNew;
|
|
private readonly IReadOnlyList<string>? _modIds;
|
|
private readonly MapLayout? _savedMap;
|
|
private readonly string? _nameSetId;
|
|
private string? _nativeLanguage;
|
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
|
private readonly Action<int> _onFailed;
|
|
|
|
private readonly int _id;
|
|
private readonly string _name;
|
|
private readonly DateTime _time;
|
|
private readonly bool _running;
|
|
private readonly int _speedIndex;
|
|
|
|
private SchoolState _snapshot;
|
|
private Roster? _rosterSnapshot;
|
|
private ApplicantPool? _applicantSnapshot;
|
|
private DefCatalog? _catalogSnapshot;
|
|
private Timetable? _timetableSnapshot;
|
|
private MapLayout? _mapSnapshot;
|
|
private int _presenceAge;
|
|
private School? _school;
|
|
private Task? _run;
|
|
private bool _persistOnStop = true;
|
|
private bool _settingsDirty;
|
|
private long _lastSettingsSave;
|
|
|
|
public SchoolWorker(
|
|
int id,
|
|
string name,
|
|
DateTime time,
|
|
bool running,
|
|
int speedIndex,
|
|
bool isNew,
|
|
IReadOnlyList<string>? modIds,
|
|
MapLayout? savedMap,
|
|
string? nameSetId,
|
|
string? nativeLanguage,
|
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
|
SimulationOptions options,
|
|
ClientRegistry clients,
|
|
GameMetrics metrics,
|
|
SchoolStore store,
|
|
ModContent mods,
|
|
Action<int> onFailed,
|
|
ILogger logger)
|
|
{
|
|
_id = id;
|
|
_name = name;
|
|
_time = time;
|
|
_running = running;
|
|
_speedIndex = speedIndex;
|
|
_isNew = isNew;
|
|
_modIds = modIds;
|
|
_savedMap = savedMap;
|
|
_nameSetId = nameSetId;
|
|
_nativeLanguage = nativeLanguage;
|
|
_savedPresence = savedPresence;
|
|
_options = options;
|
|
_clients = clients;
|
|
_metrics = metrics;
|
|
_store = store;
|
|
_mods = mods;
|
|
_onFailed = onFailed;
|
|
_logger = logger;
|
|
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? []);
|
|
}
|
|
|
|
public int Id => _id;
|
|
|
|
public Task Started => _started.Task;
|
|
|
|
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
|
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
|
|
|
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
|
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
|
|
|
/// <summary>Last applicant pool. Same publication rules as the roster — not a live World query.</summary>
|
|
public ApplicantPool? ApplicantSnapshot => Volatile.Read(ref _applicantSnapshot);
|
|
|
|
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
|
|
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
|
|
|
|
/// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
|
|
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot);
|
|
|
|
/// <summary>Frozen map instance. Safe to read from HTTP with the catalog; it does not mutate.</summary>
|
|
public MapLayout? MapSnapshot => Volatile.Read(ref _mapSnapshot);
|
|
|
|
public void Start()
|
|
{
|
|
_run = Task.Factory.StartNew(
|
|
RunSync,
|
|
CancellationToken.None,
|
|
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
|
|
TaskScheduler.Default);
|
|
}
|
|
|
|
public bool Post(WorkerCommand command)
|
|
{
|
|
if (_mailbox.Writer.TryWrite(command))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
_logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id);
|
|
return false;
|
|
}
|
|
|
|
public async Task StopAsync(bool persist)
|
|
{
|
|
Volatile.Write(ref _persistOnStop, persist);
|
|
_mailbox.Writer.TryComplete();
|
|
await _stopping.CancelAsync().ConfigureAwait(false);
|
|
|
|
if (_run is not null)
|
|
{
|
|
try
|
|
{
|
|
await _run.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal shutdown.
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RunSync()
|
|
{
|
|
try
|
|
{
|
|
RunLoop(_stopping.Token);
|
|
}
|
|
catch (SchoolContentUnavailableException ex)
|
|
{
|
|
_logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id);
|
|
_started.TrySetException(ex);
|
|
ReportFailure();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
|
_started.TrySetException(ex);
|
|
ReportFailure();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and
|
|
/// the menu kept drawing its card with a frozen clock, as if the school were alive.
|
|
/// </summary>
|
|
private void ReportFailure()
|
|
{
|
|
if (_stopping.IsCancellationRequested)
|
|
{
|
|
// Already being torn down on purpose; the supervisor knows.
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_onFailed(_id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id);
|
|
}
|
|
}
|
|
|
|
private void RunLoop(CancellationToken cancellationToken)
|
|
{
|
|
var packIds = _mods.NormalizePackIds(_modIds);
|
|
_logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds));
|
|
foreach (var packId in packIds)
|
|
{
|
|
if (!_mods.PackExists(packId))
|
|
{
|
|
throw new SchoolContentUnavailableException(
|
|
$"School {_id} needs mod '{packId}', but that folder is missing.");
|
|
}
|
|
}
|
|
|
|
var catalog = _mods.LoadCatalog(packIds, _logger);
|
|
Volatile.Write(ref _catalogSnapshot, catalog);
|
|
var map = _mods.LoadMap(packIds, _savedMap);
|
|
Volatile.Write(ref _mapSnapshot, map);
|
|
try
|
|
{
|
|
MapValidator.Validate(map, catalog);
|
|
}
|
|
catch (MapValidationException ex)
|
|
{
|
|
throw new SchoolContentUnavailableException(ex.Message, ex);
|
|
}
|
|
|
|
var school = _isNew
|
|
? School.Create(_id, _name, _time, catalog, map)
|
|
: School.Load(_id, _name, _time, _running, _speedIndex, catalog, map);
|
|
|
|
var peopleDirty = false;
|
|
try
|
|
{
|
|
peopleDirty = InstallPeople(school, catalog, map);
|
|
}
|
|
catch
|
|
{
|
|
school.Dispose();
|
|
throw;
|
|
}
|
|
|
|
_school = school;
|
|
PublishSnapshot();
|
|
|
|
if (_isNew)
|
|
{
|
|
Persist();
|
|
}
|
|
|
|
if (peopleDirty)
|
|
{
|
|
PersistPeople();
|
|
}
|
|
|
|
_started.TrySetResult();
|
|
|
|
using var timer = new PeriodicTimer(_options.TickInterval);
|
|
var fixedDelta = _options.FixedDeltaTime;
|
|
var lastTimestamp = Stopwatch.GetTimestamp();
|
|
var accumulator = 0d;
|
|
var lastSave = lastTimestamp;
|
|
var peopleChanged = false;
|
|
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
if (!WaitForTick(timer, cancellationToken))
|
|
{
|
|
break;
|
|
}
|
|
|
|
DrainMailbox();
|
|
|
|
var now = Stopwatch.GetTimestamp();
|
|
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
|
|
lastTimestamp = now;
|
|
|
|
var steps = 0;
|
|
peopleChanged = false;
|
|
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
|
|
{
|
|
var stepStarted = Stopwatch.GetTimestamp();
|
|
peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond);
|
|
_metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
|
|
|
|
accumulator -= fixedDelta;
|
|
steps++;
|
|
}
|
|
|
|
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
|
|
{
|
|
_logger.LogWarning(
|
|
"School {SchoolId} is behind by {Backlog:F0} ms; dropping the backlog.",
|
|
_id,
|
|
accumulator * 1000);
|
|
accumulator = 0d;
|
|
}
|
|
|
|
if (peopleChanged)
|
|
{
|
|
PersistPeople();
|
|
if (school.TimetableDirty)
|
|
{
|
|
RebuildTimetable(school);
|
|
}
|
|
}
|
|
|
|
if (steps > 0)
|
|
{
|
|
PublishSnapshot();
|
|
BroadcastClock();
|
|
MaybeBroadcastPresence(school);
|
|
}
|
|
|
|
FlushSettings();
|
|
|
|
if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval)
|
|
{
|
|
Persist();
|
|
lastSave = Stopwatch.GetTimestamp();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal shutdown.
|
|
}
|
|
finally
|
|
{
|
|
DrainMailbox();
|
|
|
|
if (Volatile.Read(ref _persistOnStop))
|
|
{
|
|
Persist();
|
|
}
|
|
|
|
school.Dispose();
|
|
_school = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine;
|
|
/// <see cref="School.Tick"/> then runs here, not as a pool callback.
|
|
/// </summary>
|
|
private static bool WaitForTick(PeriodicTimer timer, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return timer.WaitForNextTickAsync(cancellationToken).AsTask().GetAwaiter().GetResult();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private void DrainMailbox()
|
|
{
|
|
var school = _school;
|
|
if (school is null)
|
|
{
|
|
while (_mailbox.Reader.TryRead(out var orphan))
|
|
{
|
|
CompleteOrphan(orphan);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var dirty = false;
|
|
|
|
while (_mailbox.Reader.TryRead(out var command))
|
|
{
|
|
// Every command here was triggered by a browser. One of them failing — an oversized
|
|
// snapshot, a client that vanished mid-send — must cost that command, not the school.
|
|
try
|
|
{
|
|
switch (command)
|
|
{
|
|
case WorkerCommand.Open open:
|
|
open.Client.OpenSchoolId = _id;
|
|
SendMapSnapshot(open.Client, school);
|
|
BroadcastClockTo(open.Client, school);
|
|
SendPresence(open.Client, school);
|
|
break;
|
|
|
|
case WorkerCommand.Close close:
|
|
var leaving = _clients.Find(close.PlayerId);
|
|
if (leaving?.OpenSchoolId == _id)
|
|
{
|
|
leaving.OpenSchoolId = null;
|
|
}
|
|
|
|
break;
|
|
|
|
case WorkerCommand.SetRunning setRunning:
|
|
school.Clock.IsRunning = setRunning.Running;
|
|
dirty = true;
|
|
break;
|
|
|
|
case WorkerCommand.SetSpeed setSpeed:
|
|
school.Clock.SpeedIndex = setSpeed.SpeedIndex;
|
|
dirty = true;
|
|
break;
|
|
|
|
case WorkerCommand.SkipEmpty:
|
|
ApplySkip(school);
|
|
break;
|
|
|
|
case WorkerCommand.GetPerson getPerson:
|
|
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
|
getPerson.Result.TrySetResult(
|
|
card is null
|
|
? new PersonCardResult(null, PersonLookupError.UnknownPerson)
|
|
: new PersonCardResult(card, PersonLookupError.None));
|
|
break;
|
|
|
|
case WorkerCommand.HireStaff hire:
|
|
hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
|
|
break;
|
|
|
|
case WorkerCommand.AssignSubject assign:
|
|
assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject));
|
|
break;
|
|
|
|
case WorkerCommand.UnassignSubject unassign:
|
|
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
|
|
break;
|
|
|
|
case WorkerCommand.PinLesson pin:
|
|
pin.Result.TrySetResult(
|
|
ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period));
|
|
break;
|
|
|
|
case WorkerCommand.UnpinLesson unpin:
|
|
unpin.Result.TrySetResult(
|
|
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
FailCommand(command, ex);
|
|
|
|
_logger.LogError(
|
|
ex,
|
|
"Command {Command} failed for school {SchoolId}; the school keeps running.",
|
|
command.GetType().Name,
|
|
_id);
|
|
}
|
|
}
|
|
|
|
if (dirty)
|
|
{
|
|
PublishSnapshot();
|
|
BroadcastClock();
|
|
|
|
// Not written here: a client can send SetSpeed as fast as the socket allows, and each
|
|
// one used to be a synchronous file write on this thread. FlushSettings coalesces them.
|
|
_settingsDirty = true;
|
|
}
|
|
}
|
|
|
|
private static void CompleteOrphan(WorkerCommand command)
|
|
{
|
|
switch (command)
|
|
{
|
|
case WorkerCommand.GetPerson getPerson:
|
|
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
|
break;
|
|
case WorkerCommand.HireStaff hire:
|
|
hire.Result.TrySetResult(Staffing.UnknownSchool());
|
|
break;
|
|
case WorkerCommand.AssignSubject assign:
|
|
assign.Result.TrySetResult(Staffing.UnknownSchool());
|
|
break;
|
|
case WorkerCommand.UnassignSubject unassign:
|
|
unassign.Result.TrySetResult(Staffing.UnknownSchool());
|
|
break;
|
|
case WorkerCommand.PinLesson pin:
|
|
pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
|
|
break;
|
|
case WorkerCommand.UnpinLesson unpin:
|
|
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static void FailCommand(WorkerCommand command, Exception exception)
|
|
{
|
|
switch (command)
|
|
{
|
|
case WorkerCommand.GetPerson getPerson:
|
|
getPerson.Result.TrySetException(exception);
|
|
break;
|
|
case WorkerCommand.HireStaff hire:
|
|
hire.Result.TrySetException(exception);
|
|
break;
|
|
case WorkerCommand.AssignSubject assign:
|
|
assign.Result.TrySetException(exception);
|
|
break;
|
|
case WorkerCommand.UnassignSubject unassign:
|
|
unassign.Result.TrySetException(exception);
|
|
break;
|
|
case WorkerCommand.PinLesson pin:
|
|
pin.Result.TrySetException(exception);
|
|
break;
|
|
case WorkerCommand.UnpinLesson unpin:
|
|
unpin.Result.TrySetException(exception);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private StaffingOutcome ApplyHire(School school, string personId, string position) =>
|
|
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
|
Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap));
|
|
|
|
private StaffingOutcome ApplyAssign(School school, string personId, string subject) =>
|
|
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
|
Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
|
|
|
|
private StaffingOutcome ApplyUnassign(School school, string personId, string subject) =>
|
|
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
|
Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
|
|
|
|
private StaffingOutcome ApplyStaffingChange(
|
|
School school,
|
|
Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply)
|
|
{
|
|
if (school.Roster is null || school.Applicants is null || school.Catalog is null)
|
|
{
|
|
return Staffing.UnknownSchool();
|
|
}
|
|
|
|
var outcome = apply(school.Catalog, school.Roster, school.Applicants);
|
|
if (outcome.Error == StaffingError.None)
|
|
{
|
|
school.ApplyStaffing(outcome.Roster, outcome.Pool);
|
|
PersistPeople();
|
|
RebuildTimetable(school);
|
|
}
|
|
|
|
return outcome;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
|
|
/// A single click still lands within that window; a burst collapses into one write.
|
|
/// </summary>
|
|
private void FlushSettings()
|
|
{
|
|
if (!_settingsDirty || Stopwatch.GetElapsedTime(_lastSettingsSave) < _options.MinSaveInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Persist();
|
|
_settingsDirty = false;
|
|
_lastSettingsSave = Stopwatch.GetTimestamp();
|
|
}
|
|
|
|
private void PublishSnapshot()
|
|
{
|
|
var school = _school;
|
|
if (school is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Volatile.Write(
|
|
ref _snapshot,
|
|
new SchoolState(
|
|
school.Id,
|
|
school.Name,
|
|
school.Clock.Time,
|
|
school.Clock.IsRunning,
|
|
(byte)school.Clock.SpeedIndex,
|
|
school.Catalog?.PackIds ?? _modIds ?? []));
|
|
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
|
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
|
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
|
Volatile.Write(ref _mapSnapshot, school.Map);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the composition file. Not called from the 30-second clock save — the roster and
|
|
/// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick.
|
|
/// </summary>
|
|
private void PersistPeople()
|
|
{
|
|
var school = _school;
|
|
if (school?.Roster is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster, school.Applicants));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not save people for school {SchoolId}; composition stays in memory.", _id);
|
|
}
|
|
}
|
|
|
|
private void InstallTimetable(School school)
|
|
{
|
|
if (!_isNew)
|
|
{
|
|
var saved = _store.TryReadTimetable(_id);
|
|
if (saved is not null)
|
|
{
|
|
var restored = RestoreTimetable(school, saved);
|
|
school.SetTimetable(restored);
|
|
if (!saved.Lessons.SequenceEqual(restored.Lessons)
|
|
|| !saved.Uncovered.SequenceEqual(restored.Uncovered))
|
|
{
|
|
PersistTimetable(school);
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
RebuildTimetable(school, broadcast: false);
|
|
}
|
|
|
|
private Timetable RestoreTimetable(School school, Timetable saved)
|
|
{
|
|
if (school.Catalog is null || school.Map is null || school.Roster is null)
|
|
{
|
|
return saved;
|
|
}
|
|
|
|
var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
|
var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
|
|
var valid = saved.Lessons
|
|
.Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId))
|
|
.ToArray();
|
|
if (valid.Length == saved.Lessons.Count)
|
|
{
|
|
return saved;
|
|
}
|
|
|
|
var locks = valid.Where(lesson => lesson.Locked).ToArray();
|
|
return SchoolTimetables.Build(
|
|
school.Catalog,
|
|
school.Map,
|
|
school.Roster,
|
|
locks,
|
|
_options.SchoolWeekDays);
|
|
}
|
|
|
|
private void RebuildTimetable(School school, bool broadcast = true)
|
|
{
|
|
if (school.Catalog is null || school.Map is null || school.Roster is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
|
|
ApplyTable(
|
|
school,
|
|
SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays),
|
|
broadcast);
|
|
}
|
|
|
|
private TimetableOutcome ApplyPin(
|
|
School school,
|
|
string classId,
|
|
string subject,
|
|
string roomId,
|
|
int day,
|
|
int period)
|
|
{
|
|
if (school.Catalog is null || school.Map is null || school.Roster is null)
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
|
|
}
|
|
|
|
if (school.Roster.Classes.All(item => item.Id != classId))
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownClass);
|
|
}
|
|
|
|
if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract)
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownSubject);
|
|
}
|
|
|
|
if (school.Map.Rooms.All(room => room.Id != roomId))
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownRoom);
|
|
}
|
|
|
|
var teacherId = TeacherFor(school, classId, subject);
|
|
if (teacherId is null)
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.NoTeacher);
|
|
}
|
|
|
|
var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true);
|
|
var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? [])
|
|
.Where(lesson => lesson.ClassId != classId || lesson.Subject != subject
|
|
|| lesson.Day != day || lesson.Period != period)
|
|
.Append(pin)
|
|
.ToArray();
|
|
var table = SchoolTimetables.Build(
|
|
school.Catalog,
|
|
school.Map,
|
|
school.Roster,
|
|
locks,
|
|
_options.SchoolWeekDays);
|
|
if (!table.Lessons.Any(lesson =>
|
|
lesson.Locked
|
|
&& lesson.ClassId == classId
|
|
&& lesson.Subject == subject
|
|
&& lesson.RoomId == roomId
|
|
&& lesson.Day == day
|
|
&& lesson.Period == period))
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.PinRejected);
|
|
}
|
|
|
|
ApplyTable(school, table, broadcast: true);
|
|
return TimetableOutcome.Ok(table);
|
|
}
|
|
|
|
private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period)
|
|
{
|
|
if (school.Catalog is null || school.Map is null || school.Roster is null)
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
|
|
}
|
|
|
|
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
|
|
var match = locks.FirstOrDefault(lesson =>
|
|
lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period);
|
|
if (match is null)
|
|
{
|
|
return TimetableOutcome.Fail(TimetableError.UnknownLesson);
|
|
}
|
|
|
|
var next = SchoolTimetables.Build(
|
|
school.Catalog,
|
|
school.Map,
|
|
school.Roster,
|
|
locks.Where(lesson => lesson != match).ToArray(),
|
|
_options.SchoolWeekDays);
|
|
ApplyTable(school, next, broadcast: true);
|
|
return TimetableOutcome.Ok(next);
|
|
}
|
|
|
|
private static string? TeacherFor(School school, string classId, string subject)
|
|
{
|
|
var existing = school.Timetable?.Lessons.FirstOrDefault(lesson =>
|
|
lesson.ClassId == classId && lesson.Subject == subject);
|
|
if (existing is not null)
|
|
{
|
|
return existing.TeacherId;
|
|
}
|
|
|
|
return school.Roster?.People
|
|
.Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal))
|
|
.OrderBy(person => person.Id, StringComparer.Ordinal)
|
|
.Select(person => person.Id)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private void ApplyTable(School school, Timetable table, bool broadcast)
|
|
{
|
|
school.SetTimetable(table);
|
|
PersistTimetable(school);
|
|
PublishSnapshot();
|
|
if (broadcast)
|
|
{
|
|
BroadcastPresence();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the lesson table. Not called from the 30-second clock save — the table changes on
|
|
/// hire, unassign, pin and yearly intake, not every tick.
|
|
/// </summary>
|
|
private void PersistTimetable(School school)
|
|
{
|
|
if (school.Timetable is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_store.SaveTimetable(school.Id, school.Timetable);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id);
|
|
}
|
|
}
|
|
|
|
private void MaybeBroadcastPresence(School school)
|
|
{
|
|
_presenceAge++;
|
|
var interval = Math.Max(1, _options.TickRate / 2);
|
|
if (_presenceAge < interval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_presenceAge = 0;
|
|
BroadcastPresence();
|
|
}
|
|
|
|
private void ApplySkip(School school)
|
|
{
|
|
var result = school.TrySkipEmpty();
|
|
if (!result.Succeeded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (result.PeopleChanged)
|
|
{
|
|
PersistPeople();
|
|
if (school.TimetableDirty)
|
|
{
|
|
RebuildTimetable(school);
|
|
}
|
|
}
|
|
|
|
PublishSnapshot();
|
|
Persist();
|
|
BroadcastClock();
|
|
BroadcastPresence();
|
|
_presenceAge = 0;
|
|
}
|
|
|
|
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
|
|
{
|
|
var nameSetId = ResolveNameSetId(catalog, _nameSetId);
|
|
if (nameSetId is null)
|
|
{
|
|
throw new SchoolContentUnavailableException($"School {_id} has no name set in its catalog.");
|
|
}
|
|
|
|
var demand = SchoolDemand.From(catalog, map);
|
|
Roster roster;
|
|
ApplicantPool applicants;
|
|
int seed;
|
|
var generated = false;
|
|
string? native;
|
|
|
|
if (_isNew)
|
|
{
|
|
seed = school.Id;
|
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
|
_nativeLanguage = native;
|
|
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
|
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
|
|
generated = true;
|
|
}
|
|
else
|
|
{
|
|
var loaded = _store.TryReadPeople(_id);
|
|
if (loaded is null)
|
|
{
|
|
seed = school.Id;
|
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
|
_nativeLanguage = native;
|
|
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
|
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
|
|
generated = true;
|
|
}
|
|
else
|
|
{
|
|
seed = loaded.Seed;
|
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false);
|
|
_nativeLanguage = native;
|
|
roster = loaded.ToRoster();
|
|
if (loaded.Applicants is { Applicants.Count: > 0 })
|
|
{
|
|
applicants = loaded.Applicants;
|
|
}
|
|
else
|
|
{
|
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
|
|
generated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!RosterFit.Matches(roster, demand))
|
|
{
|
|
throw new SchoolContentUnavailableException(
|
|
$"School {_id} roster does not match its map; the people file was left untouched.");
|
|
}
|
|
|
|
school.InstallPeople(roster, seed, nameSetId, applicants, _nativeLanguage);
|
|
InstallTimetable(school);
|
|
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
|
|
school.RestorePresence(_savedPresence);
|
|
return generated;
|
|
}
|
|
|
|
private static string? ResolveNameSetId(DefCatalog catalog, string? requested)
|
|
{
|
|
var available = catalog.NameSets.Values
|
|
.Where(def => !def.Abstract)
|
|
.Select(def => def.DefName)
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (available.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(requested))
|
|
{
|
|
return available[0];
|
|
}
|
|
|
|
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
|
|
}
|
|
|
|
private static string? ResolveNative(
|
|
DefCatalog catalog,
|
|
string nameSetId,
|
|
int schoolSeed,
|
|
string? requested,
|
|
bool generating)
|
|
{
|
|
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return NativeLanguages.Pick(names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
|
|
}
|
|
|
|
private void Persist()
|
|
{
|
|
var school = _school;
|
|
if (school is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// A full disk or a locked file must not end the school; the next save will try again.
|
|
try
|
|
{
|
|
_store.Save(new SchoolSave
|
|
{
|
|
Format = SchoolStore.CurrentFormat,
|
|
Id = school.Id,
|
|
Name = school.Name,
|
|
GameTime = school.Clock.Time,
|
|
Running = school.Clock.IsRunning,
|
|
SpeedIndex = school.Clock.SpeedIndex,
|
|
ModIds = school.Catalog?.PackIds,
|
|
Map = school.Map,
|
|
NameSetId = _nameSetId,
|
|
NativeLanguage = _nativeLanguage,
|
|
Presence = school.CapturePresence(),
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not save school {SchoolId}; it keeps running unsaved.", _id);
|
|
}
|
|
}
|
|
|
|
private void BroadcastClock()
|
|
{
|
|
var school = _school;
|
|
if (school is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var client in _clients.All)
|
|
{
|
|
if (client.IsReady && client.OpenSchoolId == _id)
|
|
{
|
|
BroadcastClockTo(client, school);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SendMapSnapshot(GameClient client, School school)
|
|
{
|
|
if (school.Catalog is null || school.Map is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var locale = ProtocolConstants.CatalogLocale(client.Locale);
|
|
var view = MapView.Build(school.Catalog, school.Map, locale);
|
|
var nodes = new MapSnapshotNode[view.Count];
|
|
for (var i = 0; i < view.Count; i++)
|
|
{
|
|
var node = view[i];
|
|
var items = new MapSnapshotItem[node.Items.Count];
|
|
for (var item = 0; item < node.Items.Count; item++)
|
|
{
|
|
items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count);
|
|
}
|
|
|
|
nodes[i] = new MapSnapshotNode(
|
|
(byte)node.Kind,
|
|
node.Id,
|
|
node.ParentId,
|
|
node.Name,
|
|
(ushort)node.PupilSlots,
|
|
items,
|
|
node.Positions);
|
|
}
|
|
|
|
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
|
|
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
|
|
var message = new ServerMapSnapshotMessage(school.Id, nodes);
|
|
var frame = new byte[ProtocolCodec.MapSnapshotSize(message)];
|
|
var length = ProtocolCodec.WriteMapSnapshot(frame, message);
|
|
client.TrySendReliable(frame.AsMemory(0, length));
|
|
}
|
|
|
|
private void BroadcastClockTo(GameClient client, School school)
|
|
{
|
|
var skip = school.PeekSkipEmpty();
|
|
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
|
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
|
|
school.Id,
|
|
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
|
|
school.Clock.IsRunning,
|
|
(byte)school.Clock.SpeedIndex,
|
|
skip.Allowed,
|
|
skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0));
|
|
|
|
client.TrySend(frame.AsMemory(0, length));
|
|
}
|
|
|
|
private void BroadcastPresence()
|
|
{
|
|
var school = _school;
|
|
if (school is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var client in _clients.All)
|
|
{
|
|
if (client.IsReady && client.OpenSchoolId == _id)
|
|
{
|
|
SendPresence(client, school);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SendPresence(GameClient client, School school)
|
|
{
|
|
var locale = ProtocolConstants.CatalogLocale(client.Locale);
|
|
var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale);
|
|
var frame = new byte[ProtocolCodec.PresenceSize(message)];
|
|
var length = ProtocolCodec.WritePresence(frame, message);
|
|
client.TrySendReliable(frame.AsMemory(0, length));
|
|
}
|
|
}
|