Implement per-school save functionality by introducing a dedicated save directory and updating the school management system to support loading and saving school states. Revise documentation to reflect these changes, including updates to the architecture and design documents, and enhance the API for reloading schools from disk. Update tests to ensure proper functionality of the new save and reload features.
ci / server (push) Failing after 3m40s
ci / client (push) Successful in 18s

This commit is contained in:
Leonid Pershin
2026-08-18 14:07:23 +03:00
parent f50f6eacf8
commit 37c39a3beb
28 changed files with 1032 additions and 219 deletions
+338
View File
@@ -0,0 +1,338 @@
using System.Diagnostics;
using System.Threading.Channels;
using HSchool.Protocol;
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
/// 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 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 int _id;
private readonly string _name;
private readonly DateTime _time;
private readonly bool _running;
private readonly int _speedIndex;
private SchoolState _snapshot;
private School? _school;
private Task? _run;
private bool _persistOnStop = true;
public SchoolWorker(
int id,
string name,
DateTime time,
bool running,
int speedIndex,
bool isNew,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
SchoolStore store,
ILogger logger)
{
_id = id;
_name = name;
_time = time;
_running = running;
_speedIndex = speedIndex;
_isNew = isNew;
_options = options;
_clients = clients;
_metrics = metrics;
_store = store;
_logger = logger;
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
}
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);
public void Start()
{
_run = Task.Factory.StartNew(
RunSync,
CancellationToken.None,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
}
public void Post(WorkerCommand command)
{
if (!_mailbox.Writer.TryWrite(command))
{
_logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id);
}
}
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 (Exception ex)
{
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
_started.TrySetException(ex);
}
}
private void RunLoop(CancellationToken cancellationToken)
{
var school = _isNew
? School.Create(_id, _name, _time)
: School.Load(_id, _name, _time, _running, _speedIndex);
_school = school;
PublishSnapshot();
if (_isNew)
{
Persist();
}
_started.TrySetResult();
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
var lastSave = lastTimestamp;
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;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
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 (steps > 0)
{
PublishSnapshot();
BroadcastClock();
}
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)
{
return;
}
var dirty = false;
while (_mailbox.Reader.TryRead(out var command))
{
switch (command)
{
case WorkerCommand.Open open:
open.Client.OpenSchoolId = _id;
BroadcastClockTo(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;
}
}
if (dirty)
{
PublishSnapshot();
BroadcastClock();
Persist();
}
}
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));
}
private void Persist()
{
var school = _school;
if (school is null)
{
return;
}
_store.Save(new SchoolSave(
SchoolStore.CurrentFormat,
school.Id,
school.Name,
school.Clock.Time,
school.Clock.IsRunning,
school.Clock.SpeedIndex));
}
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 static void BroadcastClockTo(GameClient client, School school)
{
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));
client.TrySend(frame.AsMemory(0, length));
}
}