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
+273 -146
View File
@@ -1,4 +1,3 @@
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -7,81 +6,75 @@ using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns every <see cref="School"/> and drives them at a fixed rate: drain commands, advance the
/// running calendars, push a clock frame to each connection that has a school open.
/// Schools are touched from this thread only.
/// Thin supervisor: create/delete/load schools, route commands into per-school mailboxes, expose
/// published snapshots to the menu. It never touches a <see cref="School"/> or its <c>World</c>.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
SchoolStore store,
ILoggerFactory loggerFactory,
ILogger<GameLoopService> logger) : BackgroundService
{
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly SchoolRegistry _schools = new(options.Value);
private readonly SchoolNameGenerator _names = new();
private readonly Dictionary<int, SchoolWorker> _workers = [];
private readonly List<int> _order = [];
private uint _currentTick;
private SchoolsState _publishedState = new(options.Value.MaxSchools, []);
private SchoolWorker[] _publishedWorkers = [];
private int _currentTick;
private int _nextId = 1;
public uint CurrentTick => Volatile.Read(ref _currentTick);
public uint CurrentTick => (uint)Volatile.Read(ref _currentTick);
public SimulationOptions Options => _options;
/// <summary>
/// Last state published by the loop thread. Menu requests read this instead of blocking on a
/// command; it is at most one tick (50 ms) behind.
/// Menu requests read this instead of blocking a worker. Each card's clock is whatever that
/// school's worker last published.
/// </summary>
public SchoolsState SchoolsState => Volatile.Read(ref _publishedState);
public SchoolsState SchoolsState
{
get
{
var workers = Volatile.Read(ref _publishedWorkers);
var schools = new SchoolState[workers.Length];
for (var i = 0; i < workers.Length; i++)
{
schools[i] = workers[i].Snapshot;
}
return new SchoolsState(_options.MaxSchools, schools);
}
}
public Task ReloadFromDiskAsync(CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
commands.Enqueue(new GameCommand.ReloadSaves(completion));
return completion.Task.WaitAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.",
"School supervisor starting at {TickRate} Hz, up to {MaxSchools} schools, saves in {Directory}.",
_options.TickRate,
_options.MaxSchools,
_options.GameMinutesPerRealSecond);
store.DirectoryPath);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
await StartWorkersFromDiskAsync().ConfigureAwait(false);
_ = HeartbeatAsync(stoppingToken);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
while (await commands.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false))
{
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
while (commands.Reader.TryRead(out var command))
{
var stepStarted = Stopwatch.GetTimestamp();
_schools.Tick();
Volatile.Write(ref _currentTick, _currentTick + 1);
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
accumulator = 0d;
}
if (steps > 0)
{
PublishState();
BroadcastClocks();
await DispatchAsync(command).ConfigureAwait(false);
}
}
}
@@ -91,76 +84,128 @@ internal sealed class GameLoopService(
}
finally
{
_schools.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick);
await StopAllWorkersAsync(persist: true).ConfigureAwait(false);
logger.LogInformation("School supervisor stopped at tick {Tick}.", CurrentTick);
}
}
private void DrainCommands()
private async Task HeartbeatAsync(CancellationToken cancellationToken)
{
while (commands.TryDequeue(out var command))
using var timer = new PeriodicTimer(_options.TickInterval);
try
{
switch (command)
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
case GameCommand.CreateSchool create:
HandleCreate(create);
break;
case GameCommand.DeleteSchool delete:
HandleDelete(delete);
break;
case GameCommand.SuggestName suggest:
Complete(suggest.Result, () => _schools.SuggestName(suggest.Language));
break;
case GameCommand.OpenSchool open:
HandleOpen(open);
break;
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
break;
case GameCommand.SetRunning setRunning:
WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running);
break;
case GameCommand.SetSpeed setSpeed:
WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex);
break;
Interlocked.Increment(ref _currentTick);
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private void HandleCreate(GameCommand.CreateSchool command)
private async Task DispatchAsync(GameCommand command)
{
Complete(command.Result, () =>
switch (command)
{
var result = _schools.Create(command.Name, command.StartDate);
if (!result.Succeeded)
{
return new SchoolCreationOutcome(null, result.Error);
}
case GameCommand.CreateSchool create:
await HandleCreateAsync(create).ConfigureAwait(false);
break;
logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name);
PublishState();
case GameCommand.DeleteSchool delete:
await HandleDeleteAsync(delete).ConfigureAwait(false);
break;
return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None);
});
case GameCommand.SuggestName suggest:
Complete(suggest.Result, () => SuggestName(suggest.Language));
break;
case GameCommand.OpenSchool open:
HandleOpen(open);
break;
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId));
break;
case GameCommand.SetRunning setRunning:
RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running));
break;
case GameCommand.SetSpeed setSpeed:
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
}
}
private void HandleDelete(GameCommand.DeleteSchool command)
private async Task HandleCreateAsync(GameCommand.CreateSchool command)
{
Complete(command.Result, () =>
try
{
var deleted = _schools.Delete(command.SchoolId);
if (!deleted)
if (_workers.Count >= _options.MaxSchools)
{
return false;
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached));
return;
}
// Anyone watching it now stares at a school that no longer exists.
if (!SchoolRegistry.TryNormalizeName(command.Name, out var normalized))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
return;
}
if (!GameClock.IsValidStartDate(command.StartDate))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true);
Track(worker);
worker.Start();
try
{
await worker.Started.ConfigureAwait(false);
}
catch
{
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
throw;
}
logger.LogInformation("School {SchoolId} \"{Name}\" created.", id, normalized);
command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None));
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private async Task HandleDeleteAsync(GameCommand.DeleteSchool command)
{
try
{
if (!_workers.TryGetValue(command.SchoolId, out var worker))
{
command.Result.TrySetResult(false);
return;
}
Untrack(command.SchoolId);
foreach (var client in clients.All)
{
if (client.OpenSchoolId == command.SchoolId)
@@ -170,11 +215,30 @@ internal sealed class GameLoopService(
}
}
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
PublishState();
await worker.StopAsync(persist: false).ConfigureAwait(false);
store.Delete(command.SchoolId);
return true;
});
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
command.Result.TrySetResult(true);
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private async Task HandleReloadAsync(GameCommand.ReloadSaves command)
{
try
{
await StopAllWorkersAsync(persist: true).ConfigureAwait(false);
await StartWorkersFromDiskAsync().ConfigureAwait(false);
command.Result.TrySetResult();
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private void HandleOpen(GameCommand.OpenSchool command)
@@ -185,16 +249,15 @@ internal sealed class GameLoopService(
return;
}
var school = _schools.Find(command.SchoolId);
if (school is null)
if (!_workers.TryGetValue(command.SchoolId, out var worker))
{
SendSchoolGone(client, command.SchoolId);
return;
}
client.OpenSchoolId = school.Id;
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id);
client.OpenSchoolId = command.SchoolId;
worker.Post(new WorkerCommand.Open(client));
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, command.SchoolId);
}
/// <summary>
@@ -210,8 +273,21 @@ internal sealed class GameLoopService(
}
}
/// <summary>Applies a change to the school a connection has open, if it still has one.</summary>
private void WithOpenSchool(uint playerId, Action<School> change)
private string SuggestName(SchoolNameLanguage language)
{
var taken = SchoolsState.Schools.Select(school => school.Name);
return _names.Next(taken, language);
}
private void Route(int schoolId, WorkerCommand command)
{
if (_workers.TryGetValue(schoolId, out var worker))
{
worker.Post(command);
}
}
private void RouteOpenSchool(uint playerId, WorkerCommand command)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId)
@@ -219,60 +295,111 @@ internal sealed class GameLoopService(
return;
}
var school = _schools.Find(schoolId);
if (school is not null)
{
change(school);
}
Route(schoolId, command);
}
private void BroadcastClocks()
private async Task StartWorkersFromDiskAsync()
{
foreach (var client in clients.All)
var saves = store.LoadAll();
var nextId = Math.Max(store.ReadNextId(), 1);
if (saves.Count > 0)
{
if (!client.IsReady || client.OpenSchoolId is not { } schoolId)
{
continue;
}
nextId = Math.Max(nextId, saves.Max(save => save.Id) + 1);
}
var school = _schools.Find(schoolId);
if (school is null)
{
continue;
}
_nextId = nextId;
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));
if (saves.Count > _options.MaxSchools)
{
logger.LogWarning(
"Found {Count} school saves but the limit is {Max}; starting the first {Max}.",
saves.Count,
_options.MaxSchools,
_options.MaxSchools);
saves = [.. saves.Take(_options.MaxSchools)];
}
client.TrySend(frame.AsMemory(0, length));
foreach (var save in saves)
{
var worker = SpawnWorker(
save.Id,
save.Name,
save.GameTime,
save.Running,
save.SpeedIndex,
isNew: false);
Track(worker);
worker.Start();
}
if (_workers.Count > 0)
{
await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false);
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
}
}
private void SendSchoolGone(GameClient client, int schoolId)
private async Task StopAllWorkersAsync(bool persist)
{
var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray();
_workers.Clear();
_order.Clear();
PublishWorkers();
if (stopping.Length > 0)
{
await Task.WhenAll(stopping).ConfigureAwait(false);
}
}
private SchoolWorker SpawnWorker(int id, string name, DateTime time, bool running, int speedIndex, bool isNew) =>
new(
id,
name,
time,
running,
speedIndex,
isNew,
_options,
clients,
metrics,
store,
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
private void Track(SchoolWorker worker)
{
_workers[worker.Id] = worker;
_order.Add(worker.Id);
PublishWorkers();
}
private void Untrack(int id)
{
_workers.Remove(id);
_order.Remove(id);
PublishWorkers();
}
private void PublishWorkers()
{
var snapshot = new SchoolWorker[_order.Count];
for (var i = 0; i < _order.Count; i++)
{
snapshot[i] = _workers[_order[i]];
}
Volatile.Write(ref _publishedWorkers, snapshot);
metrics.SchoolsChanged(snapshot.Length);
}
private static void SendSchoolGone(GameClient client, int schoolId)
{
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
client.TrySend(frame.AsMemory(0, length));
}
private void PublishState()
{
var snapshot = new SchoolsState(
_schools.MaxSchools,
_schools.Schools.Select(Capture).ToArray());
Volatile.Write(ref _publishedState, snapshot);
metrics.SchoolsChanged(snapshot.Schools.Count);
}
private static SchoolState Capture(School school) =>
new(school.Id, school.Name, school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex);
/// <summary>Runs work for a waiting request thread without letting an exception kill the loop.</summary>
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
try