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
+3
View File
@@ -418,3 +418,6 @@ FodyWeavers.xsd
# h-school
src/HSchool.Client/dist/
*.tsbuildinfo
# Per-school save files written next to the server when running locally
**/saves/
+13 -8
View File
@@ -11,7 +11,7 @@ way; this file is *how to work in them*.
| schools, the game clock, game rules | `src/HSchool.Simulation` |
| the menu API (list, create, delete) | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, the loop | `src/HSchool.Server` |
| connection handling, workers, saves | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| screens, dialogs, formatting, UI language | `src/HSchool.Client/src` |
@@ -59,9 +59,11 @@ say so explicitly in the change description.
`docs/protocol.md` change in the same commit. A layout change bumps
`ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets —
if one of them has to change, so do the other two.
3. **Only the loop thread touches a `School` or the `SchoolRegistry`.** Everything inbound goes
through `GameCommandQueue`; menu reads use the immutable state the loop publishes; everything
outbound goes through the per-client outbox. No locks around the registry, no `Task.Run` into it.
3. **Only a school's worker thread touches that `School` or its `World`.** The supervisor
owns the mailbox table and never calls into a world. Create/delete/name suggestions go
through `GameCommandQueue`; open/close/running/speed go to that school's mailbox; menu
reads use the snapshots workers publish; everything outbound goes through the per-client
outbox. No locks around a school, no `Task.Run` into it.
4. **The simulation knows nothing about the network.** `HSchool.Simulation` must not reference
ASP.NET Core, sockets or logging infrastructure. It stays testable without a host.
5. **Fixed timestep.** The clock advances by `SimulationOptions.FixedDeltaTime`, never by
@@ -103,8 +105,11 @@ say so explicitly in the change description.
- Server wiring, endpoints and the WebSocket belong in `tests/HSchool.AppHost.Tests`. That suite
shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs
about ten seconds each.
- Those tests also share one server, so schools survive between them: start each test by clearing
the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty.
- Those tests also share one server, so schools survive between them (and now on disk too):
start each test by clearing the list (`SchoolApiTests.ResetAsync`) instead of assuming it
is empty. Reset deletes through the API, which deletes the save files. Headless AppHost
sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing
the shared fixture.
- The screens have no unit tests — a DOM environment would cost a dependency the project does not
have. Verify UI changes by running the app. Dictionaries and date formatting are covered in
Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`).
@@ -125,8 +130,8 @@ say so explicitly in the change description.
- **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration
arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own
time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason.
- `PeriodicTimer` does not catch up on its own. The accumulator in `GameLoopService` does, capped
at 5 steps — do not "simplify" it away.
- `PeriodicTimer` does not catch up on its own. The accumulator in each `SchoolWorker` does,
capped at 5 steps — do not "simplify" it away.
- Every school runs whether or not a connection is watching it, so anything you hang off the tick
runs six times over once six schools exist. `OpenSchool` only subscribes to clock frames.
- The menu polls `GET /api/schools` once a second and patches its cards in place. Rebuilding the
+2 -2
View File
@@ -52,7 +52,7 @@ Time runs at 5 game minutes per real second at ×1. **Every school runs on its o
not you are inside it — the menu cards keep counting. Only the pause button stops a school, and it
stays paused (the card says so) until you press play again.
Schools live in server memory: restarting the server clears them.
Schools are written under `saves/` on the server. Restarting the process brings them back.
## Run the pieces separately
@@ -74,7 +74,7 @@ dotnet test
```
- `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts.
- `tests/HSchool.Simulation.Tests` — the game clock and the school registry, no host involved.
- `tests/HSchool.Simulation.Tests` — the game clock, school load/create, and the school registry, no host involved.
- `tests/HSchool.AppHost.Tests` — boots the real Aspire graph, drives the menu API and the
WebSocket clock. Runs headless (`--HSchool:Headless=true`), so no Node install is needed.
+37 -24
View File
@@ -9,12 +9,13 @@ there is no UI on the server.
│ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │
│ │ │ │ (Vite + DOM) │ │
│ │ GameLoopService 20 Hz │ WebSocket /ws/game │ │ │
│ │ GameLoopService │ WebSocket /ws/game │ │ │
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
│ │ ├── SchoolRegistry │ └──────────────────┘ │
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
│ │ │ └── School │ │
│ │ │ ├─ Clock│ │
│ │ │ └─ World│ (Arch ECS, empty for now) │
│ │ ├── SchoolStore │ saves/{id}.json │
│ │ └── ClientRegistry │ │
│ └────────────────────────┘ │
│ │ OTLP logs / traces / metrics │
@@ -29,7 +30,7 @@ there is no UI on the server.
| --- | --- |
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. No ASP.NET, no sockets — this is what unit tests exercise. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, the loop that drives the schools. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, disk saves. |
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
@@ -43,26 +44,29 @@ The menu is request/response — you list, create and delete saves — so it is
The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both
are described in [`protocol.md`](protocol.md).
## The tick
## The supervisor and the workers
`GameLoopService` wakes on a `PeriodicTimer` at the configured rate (20 Hz by default) and, for
each wake-up:
`GameLoopService` is a thin supervisor. It does not tick calendars and it does not touch a
`World`. It:
1. **Drains the command queue.** Create, delete, open, close and clock changes all arrive from
request or connection threads as `GameCommand` records. This is the only way anything mutates a
school.
2. **Advances every running school** by a fixed delta (`1 / TickRate`), catching up at most 5 steps
if the host stalled; a longer backlog is dropped with a warning.
3. **Publishes the menu state** an immutable `SchoolsState` the HTTP handlers read without
blocking — and **pushes a clock frame** to every connection that has a school open.
1. **Drains `GameCommandQueue`.** Create, delete and name suggestions stay here. Open, close,
running and speed are forwarded to that school's mailbox.
2. **Owns the table of workers.** Each school is a `SchoolWorker` on a dedicated
`LongRunning` thread with its own `PeriodicTimer`, its own `School` (clock + world) and its
own save file.
3. **Exposes menu state.** Each worker publishes an immutable `SchoolState`; HTTP handlers read
those snapshots without blocking the worker.
The registry is single-threaded on purpose: only the loop thread touches `SchoolRegistry` or any
`School`. Everything else communicates through `GameCommandQueue` (inbound), the published state
(menu reads) and per-client outboxes (outbound). If you find yourself wanting a lock, you are
probably about to break it.
The worker advances its school by a fixed delta (`1 / TickRate`), catching up at most 5 steps if
it stalled; a longer backlog is dropped with a warning. Clock frames go from that worker into the
outboxes of connections that have this school open.
Only that worker thread touches its `School` or `World`. Everything else communicates through
mailboxes (inbound), published snapshots (menu reads) and per-client outboxes (outbound). If you
find yourself wanting a lock, you are probably about to break it.
Commands that a request must wait for — create, delete, name suggestion — carry a
`TaskCompletionSource` the loop thread completes. That is how a POST gets its answer without ever
`TaskCompletionSource` the supervisor completes. That is how a POST gets its answer without ever
touching a school itself.
## Schools
@@ -79,23 +83,31 @@ produces the same date.
**Every school runs on its own.** A new school starts living immediately and keeps going whether
or not anybody is looking at it; only the player's pause button stops one, and that pause sticks
until they press play again. Opening a school subscribes the connection to its clock frames and
nothing more.
nothing more. One school's pause cannot stall another's calendar, because they do not share a
thread.
The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is
how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh
cannot land between a mouse-down and a click.
## Saves
Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json`
for the next id). The worker writes on create, pause, speed change, shutdown, and on a rare clock
snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The supervisor reloads the
directory at process start. A file that cannot be read is left in place and logged.
## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello`; a version mismatch closes the socket.
3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready.
4. Opening a school enqueues `OpenSchool`; from the next tick on, clock frames arrive.
5. `SetRunning` and `SetSpeed` drive the calendar; `CloseSchool` goes back to the menu.
4. Opening a school enqueues `OpenSchool`; the worker starts pushing clock frames.
5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu.
6. On disconnect the client is removed; the school it was watching keeps running.
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate clock frames instead of stalling the loop.
that cannot keep up loses intermediate clock frames instead of stalling a worker.
## Where to add things next
@@ -103,5 +115,6 @@ that cannot keep up loses intermediate clock frames instead of stalling the loop
`School.Tick`, and unit-test them against `School` directly — no server needed.
- **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from
the server after every change, so nothing else has to know.
- **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks
into create/delete plus a periodic snapshot from the loop thread.
- **Defs, map, mods**: the save record is intentionally small so later slices can add layout and
pack ids without a second persistence mechanism. That work lives in
[`phases/03-defs-map.md`](phases/03-defs-map.md).
+2 -3
View File
@@ -1,8 +1,7 @@
# Рантайм школы: поток и ECS
Договорённость на ближайшее планирование. Сейчас в коде **не так**: один `GameLoopService`
тикает все школы на одном потоке, и это записано в `AGENTS.md` как инвариант. Ниже — целевая
модель. Менять инвариант в рабочих соглашениях имеет смысл только вместе с кодом.
Договорённость, которую код фазы 2 уже выполняет: каждая школа — свой работник, свой Arch
`World`, свой файл. Инвариант в `AGENTS.md` совпадает с этим текстом.
Экран и defs: [`near-term.md`](near-term.md), [`defs.md`](defs.md).
Куда класть код: [`projects.md`](projects.md).
+9 -9
View File
@@ -11,15 +11,15 @@
## Задачи
- [ ] Супервизор держит ящики `id →` очередь; не трогает `World`
- [ ] У школы выделенный поток (`LongRunning`), свой таймер фиксированного шага, свой `World`
- [ ] Команды create/delete/open/close/running/speed идут в ящик, не в общий цикл
- [ ] Меню читает опубликованные снимки, как сейчас, без блокировки работника
- [ ] Кадры часов по-прежнему из работника в outbox соединения
- [ ] Сейв на диск: имя, id, часы, running, speedIndex; запись при create/delete, shutdown, редкий снимок часов (не 20 Гц)
- [ ] Старт процесса поднимает школы с диска
- [ ] Обновить инвариант в `AGENTS.md`: трогает школу только её работник
- [ ] Тесты AppHost: create → рестарт хоста (или явный reload) → школа на месте с тем же временем с разумной погрешностью снимка
- [x] Супервизор держит ящики `id →` очередь; не трогает `World`
- [x] У школы выделенный поток (`LongRunning`), свой таймер фиксированного шага, свой `World`
- [x] Команды create/delete/open/close/running/speed идут в ящик, не в общий цикл
- [x] Меню читает опубликованные снимки, как сейчас, без блокировки работника
- [x] Кадры часов по-прежнему из работника в outbox соединения
- [x] Сейв на диск: имя, id, часы, running, speedIndex; запись при create/delete, shutdown, редкий снимок часов (не 20 Гц)
- [x] Старт процесса поднимает школы с диска
- [x] Обновить инвариант в `AGENTS.md`: трогает школу только её работник
- [x] Тесты AppHost: create → рестарт хоста (или явный reload) → школа на месте с тем же временем с разумной погрешностью снимка
## Критерий готовности
+1 -1
View File
@@ -13,6 +13,6 @@
| --- | --- | --- |
| [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется |
| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных |
| [2. Работник школы и диск](02-school-worker.md) | | Поток + World + сейв — основа |
| [2. Работник школы и диск](02-school-worker.md) | | Поток + World + сейв — основа |
| [3. Каталог def и карта](03-defs-map.md) | ⬜ | JSONC, core, валидация раскладки |
| [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии |
+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.");
}
}
}
+252 -125
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,27 +84,42 @@ 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
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
Interlocked.Increment(ref _currentTick);
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private async Task DispatchAsync(GameCommand command)
{
switch (command)
{
case GameCommand.CreateSchool create:
HandleCreate(create);
await HandleCreateAsync(create).ConfigureAwait(false);
break;
case GameCommand.DeleteSchool delete:
HandleDelete(delete);
await HandleDeleteAsync(delete).ConfigureAwait(false);
break;
case GameCommand.SuggestName suggest:
Complete(suggest.Result, () => _schools.SuggestName(suggest.Language));
Complete(suggest.Result, () => SuggestName(suggest.Language));
break;
case GameCommand.OpenSchool open:
@@ -120,47 +128,84 @@ internal sealed class GameLoopService(
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId));
break;
case GameCommand.SetRunning setRunning:
WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running);
RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running));
break;
case GameCommand.SetSpeed setSpeed:
WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex);
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
}
}
private async Task HandleCreateAsync(GameCommand.CreateSchool command)
{
try
{
if (_workers.Count >= _options.MaxSchools)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached));
return;
}
private void HandleCreate(GameCommand.CreateSchool command)
if (!SchoolRegistry.TryNormalizeName(command.Name, out var normalized))
{
Complete(command.Result, () =>
{
var result = _schools.Create(command.Name, command.StartDate);
if (!result.Succeeded)
{
return new SchoolCreationOutcome(null, result.Error);
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
return;
}
logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name);
PublishState();
return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None);
});
if (!GameClock.IsValidStartDate(command.StartDate))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate));
return;
}
private void HandleDelete(GameCommand.DeleteSchool command)
var id = _nextId++;
store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true);
Track(worker);
worker.Start();
try
{
Complete(command.Result, () =>
await worker.Started.ConfigureAwait(false);
}
catch
{
var deleted = _schools.Delete(command.SchoolId);
if (!deleted)
{
return false;
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
throw;
}
// Anyone watching it now stares at a school that no longer exists.
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)
Route(schoolId, command);
}
private async Task StartWorkersFromDiskAsync()
{
change(school);
var saves = store.LoadAll();
var nextId = Math.Max(store.ReadNextId(), 1);
if (saves.Count > 0)
{
nextId = Math.Max(nextId, saves.Max(save => save.Id) + 1);
}
_nextId = nextId;
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)];
}
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 BroadcastClocks()
private async Task StopAllWorkersAsync(bool persist)
{
foreach (var client in clients.All)
{
if (!client.IsReady || client.OpenSchoolId is not { } schoolId)
{
continue;
}
var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray();
_workers.Clear();
_order.Clear();
PublishWorkers();
var school = _schools.Find(schoolId);
if (school is null)
if (stopping.Length > 0)
{
continue;
}
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));
await Task.WhenAll(stopping).ConfigureAwait(false);
}
}
private void SendSchoolGone(GameClient client, int schoolId)
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);
}
+52 -1
View File
@@ -5,7 +5,6 @@ namespace HSchool.AppHost.Tests;
/// <summary>
/// Talks to the realtime channel the way the browser does: binary frames over a WebSocket.
/// The clock only moves while a connection has the school open, which is what these assert.
/// </summary>
[Collection(AppHostCollection.Name)]
public class GameSocketTests(AppHostFixture fixture)
@@ -208,6 +207,58 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(999999, gone.SchoolId);
}
[Fact]
public async Task PausingOneSchool_DoesNotStopAnother()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var paused = await SchoolApiTests.CreateAsync(client, "На паузе", StartDate);
var running = await SchoolApiTests.CreateAsync(client, "Идёт дальше", StartDate);
using var socket = await OpenSchoolAsync(paused.Id);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
var pausedAt = await FindAsync(client, paused.Id);
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var pausedLater = await FindAsync(client, paused.Id);
var runningLater = await FindAsync(client, running.Id);
Assert.False(pausedLater.Running);
Assert.Equal(pausedAt.GameTime, pausedLater.GameTime);
Assert.True(runningLater.Running);
Assert.True(runningLater.GameTime > running.GameTime, "Pausing one school stopped the other.");
}
[Fact]
public async Task ReloadFromDisk_RestoresAPausedClock()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
var paused = await FindAsync(client, school.Id);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await FindAsync(client, school.Id);
Assert.Equal(paused.Name, restored.Name);
Assert.False(restored.Running);
Assert.Equal(paused.GameTime, restored.GameTime);
Assert.Equal(paused.SpeedIndex, restored.SpeedIndex);
}
[Fact]
public async Task Ping_IsAnsweredWithTheSameTimestamp()
{
@@ -145,6 +145,21 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
}
[Fact]
public async Task ReloadFromDisk_RestoresCreatedSchools()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "После перезагрузки", ExpectedDefaultStart);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = (await GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
Assert.Equal(created.Name, restored.Name);
Assert.True(restored.Running);
}
internal static async Task ResetAsync(HttpClient client)
{
var state = await GetSchoolsAsync(client);
@@ -0,0 +1,42 @@
namespace HSchool.Simulation.Tests;
public class SchoolTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public void Create_StartsRunningAtTheStartDate()
{
using var school = School.Create(1, "Гимназия", Start);
Assert.Equal(1, school.Id);
Assert.Equal("Гимназия", school.Name);
Assert.Equal(Start, school.Clock.Time);
Assert.True(school.Clock.IsRunning);
Assert.Equal(ClockSpeed.DefaultIndex, school.Clock.SpeedIndex);
}
[Fact]
public void Load_RestoresTimePauseAndSpeed()
{
var time = Start.AddHours(3);
using var school = School.Load(7, "Сейв", time, running: false, speedIndex: 3);
Assert.Equal(7, school.Id);
Assert.Equal("Сейв", school.Name);
Assert.Equal(time, school.Clock.Time);
Assert.False(school.Clock.IsRunning);
Assert.Equal(3, school.Clock.SpeedIndex);
Assert.Equal(DateTimeKind.Utc, school.Clock.Time.Kind);
}
[Fact]
public void Load_PausedSchool_DoesNotMoveOnTick()
{
using var school = School.Load(1, "Пауза", Start.AddMinutes(12), running: false, speedIndex: 4);
school.Tick(1d / 20d, 5d);
Assert.Equal(Start.AddMinutes(12), school.Clock.Time);
}
}