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
+9
View File
@@ -9,6 +9,15 @@ var server = builder.AddProject<Projects.HSchool_Server>("server")
// Integration tests and CI run headless: no Node, no dev server, just the game server.
var headless = builder.Configuration.GetValue("HSchool:Headless", false);
if (headless)
{
// Unique folder per AppHost boot so a crashed previous run cannot fill the school limit.
var saves = Path.Combine(Path.GetTempPath(), "h-school-tests", Guid.NewGuid().ToString("N"));
server
.WithEnvironment("Simulation__SavesDirectory", saves)
.WithEnvironment("HSchool__AllowSaveReload", "true");
}
if (!headless)
{
var client = builder.AddViteApp("client", "../HSchool.Client")
+3 -3
View File
@@ -5,11 +5,11 @@ namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
/// the loop thread as a command and awaited, so schools stay single-threaded.
/// the supervisor as a command and awaited, so each school stays on its own worker thread.
/// </summary>
internal static class SchoolEndpoints
{
/// <summary>How long a request waits for the loop thread before giving up.</summary>
/// <summary>How long a request waits for the supervisor before giving up.</summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
@@ -81,7 +81,7 @@ internal static class SchoolEndpoints
.WithName("DeleteSchool");
}
/// <summary>The loop thread must never be blocked by a continuation of a waiting request.</summary>
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
+7 -4
View File
@@ -3,8 +3,8 @@ using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a request or connection thread to the loop thread. Schools are
/// single-threaded, so every mutation and every read of live state arrives as one of these.
/// Work item handed from a request or connection thread to the supervisor. Create, delete and
/// name suggestions stay here; open/close/running/speed are forwarded to the school's worker.
/// </summary>
internal abstract record GameCommand
{
@@ -17,16 +17,19 @@ internal abstract record GameCommand
internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary>
/// <summary>A connection starts watching a school; clock frames follow from that worker.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
/// <summary>
/// Stops watching. The school id travels with the command because the connection may already
/// be gone from the registry by the time the loop thread gets here.
/// be gone from the registry by the time the supervisor gets here.
/// </summary>
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
}
+15 -5
View File
@@ -1,13 +1,23 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace HSchool.Server.Game;
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
/// <summary>
/// Multi-producer inbox for the supervisor. Create/delete/suggest-name stay here; everything
/// that mutates a live school is forwarded to that school's worker.
/// </summary>
internal sealed class GameCommandQueue
{
private readonly ConcurrentQueue<GameCommand> _commands = new();
private readonly Channel<GameCommand> _commands = Channel.CreateUnbounded<GameCommand>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
public ChannelReader<GameCommand> Reader => _commands.Reader;
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
public void Enqueue(GameCommand command)
{
if (!_commands.Writer.TryWrite(command))
{
throw new InvalidOperationException("The supervisor command queue is closed.");
}
}
}
+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
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
namespace HSchool.Server.Game;
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
/// <summary>Per-school tick counters surfaced in the Aspire dashboard.</summary>
internal sealed class GameMetrics : IDisposable
{
public const string MeterName = "HSchool.Server.Game";
@@ -2,7 +2,7 @@ using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>What the loop thread reports back after trying to create a school.</summary>
/// <summary>What the supervisor reports back after trying to create a school.</summary>
internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
+1 -1
View File
@@ -2,7 +2,7 @@ namespace HSchool.Server.Game;
/// <summary>
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
/// never leaves the loop thread.
/// never leaves its worker thread.
/// </summary>
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
+139
View File
@@ -0,0 +1,139 @@
using System.Text.Json;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>On-disk record of one school. Extra JSON fields are ignored so later slices can grow it.</summary>
internal sealed record SchoolSave(int Format, int Id, string Name, DateTime GameTime, bool Running, int SpeedIndex);
/// <summary>Allocates school ids that survive a process restart.</summary>
internal sealed record SchoolSaveIndex(int NextId);
/// <summary>
/// JSON files under <see cref="SimulationOptions.SavesDirectory"/>. The worker of a school is the
/// only writer of that school's file; the supervisor reads the directory at start and on reload.
/// </summary>
internal sealed class SchoolStore
{
public const int CurrentFormat = 1;
private const string IndexFileName = "index.json";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true,
};
private readonly ILogger<SchoolStore> _logger;
public SchoolStore(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<SchoolStore> logger)
{
_logger = logger;
var configured = options.Value.SavesDirectory;
DirectoryPath = Path.IsPathRooted(configured)
? configured
: Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured));
Directory.CreateDirectory(DirectoryPath);
logger.LogInformation("School saves directory is {Directory}.", DirectoryPath);
}
public string DirectoryPath { get; }
public int ReadNextId()
{
var path = IndexPath();
if (!File.Exists(path))
{
return 1;
}
try
{
var json = File.ReadAllText(path);
var index = JsonSerializer.Deserialize<SchoolSaveIndex>(json, Json);
return index is { NextId: > 0 } ? index.NextId : 1;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read {Path}; school ids will start from the files on disk.", path);
return 1;
}
}
public void WriteNextId(int nextId)
{
WriteAtomic(IndexPath(), new SchoolSaveIndex(nextId));
}
public IReadOnlyList<SchoolSave> LoadAll()
{
var saves = new List<SchoolSave>();
foreach (var path in Directory.EnumerateFiles(DirectoryPath, "*.json"))
{
if (string.Equals(Path.GetFileName(path), IndexFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var json = File.ReadAllText(path);
var save = JsonSerializer.Deserialize<SchoolSave>(json, Json);
if (save is null)
{
_logger.LogWarning("Save {Path} deserialized to nothing; leaving the file in place.", path);
continue;
}
if (!GameClock.IsValidStartDate(save.GameTime))
{
_logger.LogWarning(
"Save {Path} has a game time outside the supported range; leaving the file in place.",
path);
continue;
}
saves.Add(save with { GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc) });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read save {Path}; leaving the file in place.", path);
}
}
saves.Sort((left, right) => left.Id.CompareTo(right.Id));
return saves;
}
public void Save(SchoolSave save)
{
WriteAtomic(SchoolPath(save.Id), save);
}
public void Delete(int id)
{
var path = SchoolPath(id);
if (File.Exists(path))
{
File.Delete(path);
}
}
private string SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json");
private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
private static void WriteAtomic<T>(string path, T value)
{
var json = JsonSerializer.Serialize(value, Json);
var temp = path + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, path, overwrite: true);
}
}
+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));
}
}
+18
View File
@@ -0,0 +1,18 @@
using HSchool.Server.Net;
namespace HSchool.Server.Game;
/// <summary>
/// Work item for one school's worker. The supervisor never touches that school's <c>World</c>;
/// it only posts these.
/// </summary>
internal abstract record WorkerCommand
{
internal sealed record Open(GameClient Client) : WorkerCommand;
internal sealed record Close(uint PlayerId) : WorkerCommand;
internal sealed record SetRunning(bool Running) : WorkerCommand;
internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand;
}
+4 -4
View File
@@ -5,8 +5,8 @@ namespace HSchool.Server.Net;
/// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client can never
/// stall the game loop; when the outbox overflows the oldest frame is dropped, which is right for
/// a clock that is resent 20 times a second.
/// stall a school worker; when the outbox overflows the oldest frame is dropped, which is right
/// for a clock that is resent 20 times a second.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -34,8 +34,8 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the loop thread,
/// read by the connection thread on disconnect.
/// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor
/// on open/close, read by the connection thread on disconnect.
/// </summary>
public int? OpenSchoolId
{
+1 -1
View File
@@ -8,7 +8,7 @@ namespace HSchool.Server.Net;
/// <summary>
/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames
/// into commands. Everything it reads from the wire is untrusted, so frames are validated before
/// anything reaches the loop thread.
/// anything reaches a school worker.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
+14
View File
@@ -3,6 +3,7 @@ using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Configuration;
var builder = WebApplication.CreateBuilder(args);
@@ -17,11 +18,14 @@ builder.Services
.Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
@@ -51,6 +55,16 @@ app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
})
.WithName("GetGameStatus");
if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
{
app.MapPost("/api/dev/reload-schools", async (GameLoopService loop, CancellationToken cancellationToken) =>
{
await loop.ReloadFromDiskAsync(cancellationToken);
return Results.NoContent();
})
.WithName("ReloadSchoolsFromDisk");
}
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
{
+3 -1
View File
@@ -10,6 +10,8 @@
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00"
"DefaultStartDate": "2012-04-03T06:00:00",
"SavesDirectory": "saves",
"SaveIntervalSeconds": 30
}
}
+13 -1
View File
@@ -22,13 +22,25 @@ public sealed class School : IDisposable
World = World.Create();
}
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
public static School Create(int id, string name, DateTime startDate) => new(id, name, startDate);
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
public static School Load(int id, string name, DateTime time, bool running, int speedIndex)
{
var school = new School(id, name, time);
school.Clock.IsRunning = running;
school.Clock.SpeedIndex = speedIndex;
return school;
}
public int Id { get; }
public string Name { get; }
public GameClock Clock { get; }
/// <summary>The Arch world backing this school. Only the loop thread may touch it.</summary>
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
public World World { get; }
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary>
+3 -3
View File
@@ -18,8 +18,8 @@ public readonly record struct SchoolCreationResult(School? School, SchoolCreatio
}
/// <summary>
/// Every school that currently exists, plus the cap from configuration. Not thread-safe by design —
/// only the loop thread touches it, everything else goes through the command queue in the server.
/// In-memory set of schools plus the cap from configuration. Not thread-safe — unit tests and
/// name/limit checks use it; the live server gives each school its own worker instead.
/// </summary>
public sealed class SchoolRegistry : IDisposable
{
@@ -67,7 +67,7 @@ public sealed class SchoolRegistry : IDisposable
return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
}
var school = new School(_nextId++, normalized, startDate);
var school = School.Create(_nextId++, normalized, startDate);
_schools.Add(school);
return new SchoolCreationResult(school, SchoolCreationError.None);
@@ -26,8 +26,21 @@ public sealed class SimulationOptions
set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
/// <summary>
/// Directory for per-school save files. Relative paths are resolved against the content root.
/// </summary>
public string SavesDirectory { get; set; } = "saves";
/// <summary>
/// How often a running school writes its clock to disk. Create, pause, speed and shutdown
/// write immediately; the tick itself never does.
/// </summary>
public int SaveIntervalSeconds { get; set; } = 30;
/// <summary>Length of one fixed step.</summary>
public double FixedDeltaTime => 1d / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
public TimeSpan SaveInterval => TimeSpan.FromSeconds(SaveIntervalSeconds);
}