Split SchoolWorker and the person card along existing seams without a second thread or public API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 13:51:39 +03:00
co-authored by Cursor
parent 8d07b3606d
commit b48bbbcf7c
18 changed files with 2307 additions and 2193 deletions
@@ -0,0 +1,491 @@
using System.Diagnostics;
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
namespace HSchool.Server.Game;
internal sealed partial class SchoolWorker
{
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;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
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 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 countryId = ResolveCountryId(catalog, _countryId);
if (countryId is null)
{
throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog.");
}
if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract)
{
throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog.");
}
_climatePresetId = ResolveClimatePreset(country, _climatePresetId);
var demand = SchoolDemand.From(catalog, map);
Roster roster;
ApplicantPool applicants;
int seed;
var generated = false;
string? native;
if (_isNew)
{
if (_createSeed is not int createSeed)
{
throw new InvalidOperationException($"School {_id} was created without a people seed.");
}
seed = createSeed;
native = ResolveNative(country, seed, _nativeLanguage, generating: true);
_nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native);
generated = true;
}
else
{
var loaded = _store.TryReadPeople(_id);
if (loaded is null)
{
throw new SchoolContentUnavailableException(
$"School {_id} has no people file; the school was left unstarted.");
}
seed = loaded.Seed;
native = ResolveNative(country, 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, countryId, school.Clock.Time, native);
generated = true;
}
if (DressGenerator.NeedsDressing(roster, applicants))
{
roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time);
applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time);
generated = true;
}
RequireKnownApparel(catalog, roster, applicants);
}
if (OpinionGenerator.NeedsFamilyOpinions(roster))
{
roster = OpinionGenerator.SeedFamily(catalog, roster);
generated = true;
}
var assigned = OrientationGenerator.Assign(catalog, roster, seed);
generated |= !ReferenceEquals(assigned, roster);
roster = assigned;
var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed);
generated |= !ReferenceEquals(assignedPool, applicants);
applicants = assignedPool;
if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0)
{
generated = true;
}
roster = LockerAssigner.Apply(catalog, map, roster);
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, countryId, applicants, _nativeLanguage, _climatePresetId);
InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence);
return generated;
}
private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants)
{
foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person)))
{
foreach (var item in person.Items)
{
if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract)
{
throw new SchoolContentUnavailableException(
$"School roster references unusable thing '{item.Def}'.");
}
}
}
}
private static string? ResolveCountryId(DefCatalog catalog, string? requested)
{
if (string.IsNullOrWhiteSpace(requested))
{
return null;
}
return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract
? requested
: null;
}
private static string? ResolveClimatePreset(CountryDef country, string? requested)
{
if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal))
{
return requested;
}
return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false);
}
private static string? ResolveNative(
CountryDef country,
int schoolSeed,
string? requested,
bool generating) =>
NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
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,
school.Weather.Tenths,
(byte)school.Weather.Precipitation));
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));
}
}