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; /// /// 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 GetResult so /// stays on this thread instead of hopping back onto the pool. /// 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 _mailbox = Channel.CreateUnbounded( 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? _modIds; private readonly MapLayout? _savedMap; private readonly string? _nameSetId; private string? _nativeLanguage; private readonly IReadOnlyList? _savedPresence; private readonly Action _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? modIds, MapLayout? savedMap, string? nameSetId, string? nativeLanguage, IReadOnlyList? savedPresence, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, SchoolStore store, ModContent mods, Action 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; /// Last clock the worker published. Menu requests read this; the live school stays here. public SchoolState Snapshot => Volatile.Read(ref _snapshot); /// Last roster composition. Published like ; needs live on entities. public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); /// Last applicant pool. Same publication rules as the roster — not a live World query. public ApplicantPool? ApplicantSnapshot => Volatile.Read(ref _applicantSnapshot); /// Frozen catalog for this school. Safe to read from HTTP; it never mutates after load. public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot); /// Last built timetable. Published like the roster — HTTP never reads the live school. public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot); /// Frozen map instance. Safe to read from HTTP with the catalog; it does not mutate. 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(); } } /// /// 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. /// 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; } } /// /// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine; /// then runs here, not as a pool callback. /// 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.Dump dump: dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); 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.Dump dump: dump.Result.TrySetResult(null); break; 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.Dump dump: dump.Result.TrySetException(exception); break; 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 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; } /// /// Writes pause/speed changes, at most once per . /// A single click still lands within that window; a burst collapses into one write. /// 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); } /// /// 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. /// 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(); } } /// /// 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. /// 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)); } }