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 # h-school
src/HSchool.Client/dist/ src/HSchool.Client/dist/
*.tsbuildinfo *.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` | | schools, the game clock, game rules | `src/HSchool.Simulation` |
| the menu API (list, create, delete) | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | 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` | | 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` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| screens, dialogs, formatting, UI language | `src/HSchool.Client/src` | | 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 `docs/protocol.md` change in the same commit. A layout change bumps
`ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets — `ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets —
if one of them has to change, so do the other two. 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 3. **Only a school's worker thread touches that `School` or its `World`.** The supervisor
through `GameCommandQueue`; menu reads use the immutable state the loop publishes; everything owns the mailbox table and never calls into a world. Create/delete/name suggestions go
outbound goes through the per-client outbox. No locks around the registry, no `Task.Run` into it. 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 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. ASP.NET Core, sockets or logging infrastructure. It stays testable without a host.
5. **Fixed timestep.** The clock advances by `SimulationOptions.FixedDeltaTime`, never by 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 - 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 shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs
about ten seconds each. about ten seconds each.
- Those tests also share one server, so schools survive between them: start each test by clearing - Those tests also share one server, so schools survive between them (and now on disk too):
the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty. 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 - 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 have. Verify UI changes by running the app. Dictionaries and date formatting are covered in
Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`). 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 - **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 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. 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 - `PeriodicTimer` does not catch up on its own. The accumulator in each `SchoolWorker` does,
at 5 steps — do not "simplify" it away. 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 - 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. 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 - 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 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. 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 ## Run the pieces separately
@@ -74,7 +74,7 @@ dotnet test
``` ```
- `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts. - `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 - `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. 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 ┌──────────────────┐ │ │ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │ │ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │
│ │ │ │ (Vite + DOM) │ │ │ │ │ │ (Vite + DOM) │ │
│ │ GameLoopService 20 Hz │ WebSocket /ws/game │ │ │ │ │ GameLoopService │ WebSocket /ws/game │ │ │
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │ │ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
│ │ ├── SchoolRegistry │ └──────────────────┘ │ │ │ ├── SchoolWorker ×N │ └──────────────────┘ │
│ │ │ └── School │ │ │ │ │ └── School │ │
│ │ │ ├─ Clock│ │ │ │ │ ├─ Clock│ │
│ │ │ └─ World│ (Arch ECS, empty for now) │ │ │ │ └─ World│ (Arch ECS, empty for now) │
│ │ ├── SchoolStore │ saves/{id}.json │
│ │ └── ClientRegistry │ │ │ │ └── ClientRegistry │ │
│ └────────────────────────┘ │ │ └────────────────────────┘ │
│ │ OTLP logs / traces / metrics │ │ │ 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.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.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.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.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. | | `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 The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both
are described in [`protocol.md`](protocol.md). 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 `GameLoopService` is a thin supervisor. It does not tick calendars and it does not touch a
each wake-up: `World`. It:
1. **Drains the command queue.** Create, delete, open, close and clock changes all arrive from 1. **Drains `GameCommandQueue`.** Create, delete and name suggestions stay here. Open, close,
request or connection threads as `GameCommand` records. This is the only way anything mutates a running and speed are forwarded to that school's mailbox.
school. 2. **Owns the table of workers.** Each school is a `SchoolWorker` on a dedicated
2. **Advances every running school** by a fixed delta (`1 / TickRate`), catching up at most 5 steps `LongRunning` thread with its own `PeriodicTimer`, its own `School` (clock + world) and its
if the host stalled; a longer backlog is dropped with a warning. own save file.
3. **Publishes the menu state** an immutable `SchoolsState` the HTTP handlers read without 3. **Exposes menu state.** Each worker publishes an immutable `SchoolState`; HTTP handlers read
blocking — and **pushes a clock frame** to every connection that has a school open. those snapshots without blocking the worker.
The registry is single-threaded on purpose: only the loop thread touches `SchoolRegistry` or any The worker advances its school by a fixed delta (`1 / TickRate`), catching up at most 5 steps if
`School`. Everything else communicates through `GameCommandQueue` (inbound), the published state it stalled; a longer backlog is dropped with a warning. Clock frames go from that worker into the
(menu reads) and per-client outboxes (outbound). If you find yourself wanting a lock, you are outboxes of connections that have this school open.
probably about to break it.
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 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. touching a school itself.
## Schools ## 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 **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 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 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 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 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. 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 ## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id. 1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello`; a version mismatch closes the socket. 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. 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. 4. Opening a school enqueues `OpenSchool`; the worker starts pushing clock frames.
5. `SetRunning` and `SetSpeed` drive the calendar; `CloseSchool` goes back to the menu. 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. 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 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 ## 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. `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 - **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. the server after every change, so nothing else has to know.
- **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks - **Defs, map, mods**: the save record is intentionally small so later slices can add layout and
into create/delete plus a periodic snapshot from the loop thread. 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 # Рантайм школы: поток и ECS
Договорённость на ближайшее планирование. Сейчас в коде **не так**: один `GameLoopService` Договорённость, которую код фазы 2 уже выполняет: каждая школа — свой работник, свой Arch
тикает все школы на одном потоке, и это записано в `AGENTS.md` как инвариант. Ниже — целевая `World`, свой файл. Инвариант в `AGENTS.md` совпадает с этим текстом.
модель. Менять инвариант в рабочих соглашениях имеет смысл только вместе с кодом.
Экран и defs: [`near-term.md`](near-term.md), [`defs.md`](defs.md). Экран и defs: [`near-term.md`](near-term.md), [`defs.md`](defs.md).
Куда класть код: [`projects.md`](projects.md). Куда класть код: [`projects.md`](projects.md).
+9 -9
View File
@@ -11,15 +11,15 @@
## Задачи ## Задачи
- [ ] Супервизор держит ящики `id →` очередь; не трогает `World` - [x] Супервизор держит ящики `id →` очередь; не трогает `World`
- [ ] У школы выделенный поток (`LongRunning`), свой таймер фиксированного шага, свой `World` - [x] У школы выделенный поток (`LongRunning`), свой таймер фиксированного шага, свой `World`
- [ ] Команды create/delete/open/close/running/speed идут в ящик, не в общий цикл - [x] Команды create/delete/open/close/running/speed идут в ящик, не в общий цикл
- [ ] Меню читает опубликованные снимки, как сейчас, без блокировки работника - [x] Меню читает опубликованные снимки, как сейчас, без блокировки работника
- [ ] Кадры часов по-прежнему из работника в outbox соединения - [x] Кадры часов по-прежнему из работника в outbox соединения
- [ ] Сейв на диск: имя, id, часы, running, speedIndex; запись при create/delete, shutdown, редкий снимок часов (не 20 Гц) - [x] Сейв на диск: имя, id, часы, running, speedIndex; запись при create/delete, shutdown, редкий снимок часов (не 20 Гц)
- [ ] Старт процесса поднимает школы с диска - [x] Старт процесса поднимает школы с диска
- [ ] Обновить инвариант в `AGENTS.md`: трогает школу только её работник - [x] Обновить инвариант в `AGENTS.md`: трогает школу только её работник
- [ ] Тесты AppHost: create → рестарт хоста (или явный reload) → школа на месте с тем же временем с разумной погрешностью снимка - [x] Тесты AppHost: create → рестарт хоста (или явный reload) → школа на месте с тем же временем с разумной погрешностью снимка
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -13,6 +13,6 @@
| --- | --- | --- | | --- | --- | --- |
| [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется | | [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется |
| [1. Оболочка менеджера](01-manager-shell.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, валидация раскладки | | [3. Каталог def и карта](03-defs-map.md) | ⬜ | JSONC, core, валидация раскладки |
| [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии | | [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. // Integration tests and CI run headless: no Node, no dev server, just the game server.
var headless = builder.Configuration.GetValue("HSchool:Headless", false); 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) if (!headless)
{ {
var client = builder.AddViteApp("client", "../HSchool.Client") var client = builder.AddViteApp("client", "../HSchool.Client")
+3 -3
View File
@@ -5,11 +5,11 @@ namespace HSchool.Server.Api;
/// <summary> /// <summary>
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to /// 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> /// </summary>
internal static class SchoolEndpoints 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); private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder) public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
@@ -81,7 +81,7 @@ internal static class SchoolEndpoints
.WithName("DeleteSchool"); .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>() => private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
+7 -4
View File
@@ -3,8 +3,8 @@ using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
/// <summary> /// <summary>
/// Work item handed from a request or connection thread to the loop thread. Schools are /// Work item handed from a request or connection thread to the supervisor. Create, delete and
/// single-threaded, so every mutation and every read of live state arrives as one of these. /// name suggestions stay here; open/close/running/speed are forwarded to the school's worker.
/// </summary> /// </summary>
internal abstract record GameCommand internal abstract record GameCommand
{ {
@@ -17,16 +17,19 @@ internal abstract record GameCommand
internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource<string> Result) : 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; internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
/// <summary> /// <summary>
/// Stops watching. The school id travels with the command because the connection may already /// 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> /// </summary>
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand; internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand; internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : 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; 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 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.Protocol;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Simulation; using HSchool.Simulation;
@@ -7,81 +6,75 @@ using Microsoft.Extensions.Options;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
/// <summary> /// <summary>
/// Owns every <see cref="School"/> and drives them at a fixed rate: drain commands, advance the /// Thin supervisor: create/delete/load schools, route commands into per-school mailboxes, expose
/// running calendars, push a clock frame to each connection that has a school open. /// published snapshots to the menu. It never touches a <see cref="School"/> or its <c>World</c>.
/// Schools are touched from this thread only.
/// </summary> /// </summary>
internal sealed class GameLoopService( internal sealed class GameLoopService(
IOptions<SimulationOptions> options, IOptions<SimulationOptions> options,
GameCommandQueue commands, GameCommandQueue commands,
ClientRegistry clients, ClientRegistry clients,
GameMetrics metrics, GameMetrics metrics,
SchoolStore store,
ILoggerFactory loggerFactory,
ILogger<GameLoopService> logger) : BackgroundService 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 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 SchoolWorker[] _publishedWorkers = [];
private SchoolsState _publishedState = new(options.Value.MaxSchools, []); 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; public SimulationOptions Options => _options;
/// <summary> /// <summary>
/// Last state published by the loop thread. Menu requests read this instead of blocking on a /// Menu requests read this instead of blocking a worker. Each card's clock is whatever that
/// command; it is at most one tick (50 ms) behind. /// school's worker last published.
/// </summary> /// </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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation( 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.TickRate,
_options.MaxSchools, _options.MaxSchools,
_options.GameMinutesPerRealSecond); store.DirectoryPath);
using var timer = new PeriodicTimer(_options.TickInterval); await StartWorkersFromDiskAsync().ConfigureAwait(false);
var fixedDelta = _options.FixedDeltaTime; _ = HeartbeatAsync(stoppingToken);
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
try try
{ {
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) while (await commands.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false))
{ {
var now = Stopwatch.GetTimestamp(); while (commands.Reader.TryRead(out var command))
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{ {
var stepStarted = Stopwatch.GetTimestamp(); await DispatchAsync(command).ConfigureAwait(false);
_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();
} }
} }
} }
@@ -91,27 +84,42 @@ internal sealed class GameLoopService(
} }
finally finally
{ {
_schools.Dispose(); await StopAllWorkersAsync(persist: true).ConfigureAwait(false);
logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick); 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) switch (command)
{ {
case GameCommand.CreateSchool create: case GameCommand.CreateSchool create:
HandleCreate(create); await HandleCreateAsync(create).ConfigureAwait(false);
break; break;
case GameCommand.DeleteSchool delete: case GameCommand.DeleteSchool delete:
HandleDelete(delete); await HandleDeleteAsync(delete).ConfigureAwait(false);
break; break;
case GameCommand.SuggestName suggest: case GameCommand.SuggestName suggest:
Complete(suggest.Result, () => _schools.SuggestName(suggest.Language)); Complete(suggest.Result, () => SuggestName(suggest.Language));
break; break;
case GameCommand.OpenSchool open: case GameCommand.OpenSchool open:
@@ -120,47 +128,84 @@ internal sealed class GameLoopService(
case GameCommand.CloseSchool close: case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId); StopWatching(close.PlayerId, close.SchoolId);
Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId));
break; break;
case GameCommand.SetRunning setRunning: case GameCommand.SetRunning setRunning:
WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running); RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running));
break; break;
case GameCommand.SetSpeed setSpeed: 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; 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, () => command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
{ return;
var result = _schools.Create(command.Name, command.StartDate);
if (!result.Succeeded)
{
return new SchoolCreationOutcome(null, result.Error);
} }
logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name); if (!GameClock.IsValidStartDate(command.StartDate))
PublishState(); {
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate));
return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None); 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); Untrack(id);
if (!deleted) await worker.StopAsync(persist: false).ConfigureAwait(false);
{ throw;
return false;
} }
// 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) foreach (var client in clients.All)
{ {
if (client.OpenSchoolId == command.SchoolId) if (client.OpenSchoolId == command.SchoolId)
@@ -170,11 +215,30 @@ internal sealed class GameLoopService(
} }
} }
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId); await worker.StopAsync(persist: false).ConfigureAwait(false);
PublishState(); 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) private void HandleOpen(GameCommand.OpenSchool command)
@@ -185,16 +249,15 @@ internal sealed class GameLoopService(
return; return;
} }
var school = _schools.Find(command.SchoolId); if (!_workers.TryGetValue(command.SchoolId, out var worker))
if (school is null)
{ {
SendSchoolGone(client, command.SchoolId); SendSchoolGone(client, command.SchoolId);
return; return;
} }
client.OpenSchoolId = school.Id; client.OpenSchoolId = command.SchoolId;
worker.Post(new WorkerCommand.Open(client));
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id); logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, command.SchoolId);
} }
/// <summary> /// <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 string SuggestName(SchoolNameLanguage language)
private void WithOpenSchool(uint playerId, Action<School> change) {
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); var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId) if (client?.OpenSchoolId is not { } schoolId)
@@ -219,60 +295,111 @@ internal sealed class GameLoopService(
return; return;
} }
var school = _schools.Find(schoolId); Route(schoolId, command);
if (school is not null) }
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) var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray();
{ _workers.Clear();
if (!client.IsReady || client.OpenSchoolId is not { } schoolId) _order.Clear();
{ PublishWorkers();
continue;
}
var school = _schools.Find(schoolId); if (stopping.Length > 0)
if (school is null)
{ {
continue; await Task.WhenAll(stopping).ConfigureAwait(false);
}
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));
} }
} }
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 frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId)); var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
client.TrySend(frame.AsMemory(0, length)); client.TrySend(frame.AsMemory(0, length));
} }
private void PublishState() /// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
{
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>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work) private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{ {
try try
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
namespace HSchool.Server.Game; 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 internal sealed class GameMetrics : IDisposable
{ {
public const string MeterName = "HSchool.Server.Game"; public const string MeterName = "HSchool.Server.Game";
@@ -2,7 +2,7 @@ using HSchool.Simulation;
namespace HSchool.Server.Game; 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) internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
{ {
public bool Succeeded => Error == SchoolCreationError.None && School is not null; public bool Succeeded => Error == SchoolCreationError.None && School is not null;
+1 -1
View File
@@ -2,7 +2,7 @@ namespace HSchool.Server.Game;
/// <summary> /// <summary>
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object /// 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> /// </summary>
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); 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> /// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client can never /// 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 /// stall a school worker; when the outbox overflows the oldest frame is dropped, which is right
/// a clock that is resent 20 times a second. /// for a clock that is resent 20 times a second.
/// </summary> /// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket) 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); public bool IsReady => Volatile.Read(ref _ready);
/// <summary> /// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the loop thread, /// School this connection is watching, or <c>null</c> in the menu. Written by the supervisor
/// read by the connection thread on disconnect. /// on open/close, read by the connection thread on disconnect.
/// </summary> /// </summary>
public int? OpenSchoolId public int? OpenSchoolId
{ {
+1 -1
View File
@@ -8,7 +8,7 @@ namespace HSchool.Server.Net;
/// <summary> /// <summary>
/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames /// 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 /// 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> /// </summary>
internal sealed class GameSocketHandler( internal sealed class GameSocketHandler(
ClientRegistry clients, ClientRegistry clients,
+14
View File
@@ -3,6 +3,7 @@ using HSchool.Server.Api;
using HSchool.Server.Game; using HSchool.Server.Game;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Simulation; using HSchool.Simulation;
using Microsoft.Extensions.Configuration;
var builder = WebApplication.CreateBuilder(args); 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.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 => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.") .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(); .ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>(); builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>(); builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<GameMetrics>(); builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<GameSocketHandler>(); builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>(); builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
@@ -51,6 +55,16 @@ app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
}) })
.WithName("GetGameStatus"); .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. // The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) => app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
{ {
+3 -1
View File
@@ -10,6 +10,8 @@
"TickRate": 20, "TickRate": 20,
"MaxSchools": 6, "MaxSchools": 6,
"GameMinutesPerRealSecond": 5, "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(); 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 int Id { get; }
public string Name { get; } public string Name { get; }
public GameClock Clock { 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; } public World World { get; }
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary> /// <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> /// <summary>
/// Every school that currently exists, plus the cap from configuration. Not thread-safe by design — /// In-memory set of schools plus the cap from configuration. Not thread-safe — unit tests and
/// only the loop thread touches it, everything else goes through the command queue in the server. /// name/limit checks use it; the live server gives each school its own worker instead.
/// </summary> /// </summary>
public sealed class SchoolRegistry : IDisposable public sealed class SchoolRegistry : IDisposable
{ {
@@ -67,7 +67,7 @@ public sealed class SchoolRegistry : IDisposable
return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate); return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
} }
var school = new School(_nextId++, normalized, startDate); var school = School.Create(_nextId++, normalized, startDate);
_schools.Add(school); _schools.Add(school);
return new SchoolCreationResult(school, SchoolCreationError.None); return new SchoolCreationResult(school, SchoolCreationError.None);
@@ -26,8 +26,21 @@ public sealed class SimulationOptions
set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc); 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> /// <summary>Length of one fixed step.</summary>
public double FixedDeltaTime => 1d / TickRate; public double FixedDeltaTime => 1d / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(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> /// <summary>
/// Talks to the realtime channel the way the browser does: binary frames over a WebSocket. /// 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> /// </summary>
[Collection(AppHostCollection.Name)] [Collection(AppHostCollection.Name)]
public class GameSocketTests(AppHostFixture fixture) public class GameSocketTests(AppHostFixture fixture)
@@ -208,6 +207,58 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(999999, gone.SchoolId); 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] [Fact]
public async Task Ping_IsAnsweredWithTheSameTimestamp() 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."); 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) internal static async Task ResetAsync(HttpClient client)
{ {
var state = await GetSchoolsAsync(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);
}
}