537 lines
17 KiB
C#
537 lines
17 KiB
C#
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();
|
|
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
|
|
if (_notices.HasPausing)
|
|
{
|
|
school.Clock.IsRunning = false;
|
|
}
|
|
|
|
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();
|
|
EmitWorldEvents(school);
|
|
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();
|
|
EmitWorldEvents(school);
|
|
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(catalog, 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));
|
|
}
|
|
|
|
private void EmitWorldEvents(School school)
|
|
{
|
|
var catalog = school.Catalog;
|
|
if (catalog is null)
|
|
{
|
|
school.DrainWorldEvents();
|
|
return;
|
|
}
|
|
|
|
foreach (var fact in school.DrainWorldEvents())
|
|
{
|
|
foreach (var def in catalog.Events.Values)
|
|
{
|
|
if (def.Abstract || !def.Trigger.Equals(fact.Trigger, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
TryEmitNotice(school, def, personId: 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void BroadcastNotice(in ServerNoticeMessage message)
|
|
{
|
|
var frame = new byte[ProtocolCodec.NoticeSize(message)];
|
|
var length = ProtocolCodec.WriteNotice(frame, message);
|
|
var payload = frame.AsMemory(0, length);
|
|
foreach (var client in _clients.All)
|
|
{
|
|
if (client.IsReady && client.OpenSchoolId == _id)
|
|
{
|
|
client.TrySendReliable(payload);
|
|
}
|
|
}
|
|
}
|
|
}
|