diff --git a/.gitignore b/.gitignore index 501be1e..18e586f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index e618920..10236ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index dcb88fd..5885d64 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index a5e146b..4fab4fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/docs/design/runtime.md b/docs/design/runtime.md index 278f63d..9ee3263 100644 --- a/docs/design/runtime.md +++ b/docs/design/runtime.md @@ -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). diff --git a/docs/phases/02-school-worker.md b/docs/phases/02-school-worker.md index fde17cc..8179aa0 100644 --- a/docs/phases/02-school-worker.md +++ b/docs/phases/02-school-worker.md @@ -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) → школа на месте с тем же временем с разумной погрешностью снимка ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index ef2b578..0e1b908 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -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, снимок при открытии | diff --git a/src/HSchool.AppHost/AppHost.cs b/src/HSchool.AppHost/AppHost.cs index a6569b5..2904945 100644 --- a/src/HSchool.AppHost/AppHost.cs +++ b/src/HSchool.AppHost/AppHost.cs @@ -9,6 +9,15 @@ var server = builder.AddProject("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") diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index f43c787..bcae385 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -5,11 +5,11 @@ namespace HSchool.Server.Api; /// /// 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. /// internal static class SchoolEndpoints { - /// How long a request waits for the loop thread before giving up. + /// How long a request waits for the supervisor before giving up. 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"); } - /// The loop thread must never be blocked by a continuation of a waiting request. + /// The supervisor must never be blocked by a continuation of a waiting request. private static TaskCompletionSource NewCompletion() => new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 7361854..084becd 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -3,8 +3,8 @@ using HSchool.Simulation; namespace HSchool.Server.Game; /// -/// 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. /// internal abstract record GameCommand { @@ -17,16 +17,19 @@ internal abstract record GameCommand internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource Result) : GameCommand; - /// A connection starts watching a school; its calendar starts running. + /// A connection starts watching a school; clock frames follow from that worker. internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand; /// /// 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. /// 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; + + /// Stops every worker, re-reads the save directory, starts workers from those files. + internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; } diff --git a/src/HSchool.Server/Game/GameCommandQueue.cs b/src/HSchool.Server/Game/GameCommandQueue.cs index 680228c..d51568e 100644 --- a/src/HSchool.Server/Game/GameCommandQueue.cs +++ b/src/HSchool.Server/Game/GameCommandQueue.cs @@ -1,13 +1,23 @@ -using System.Collections.Concurrent; +using System.Threading.Channels; namespace HSchool.Server.Game; -/// Multi-producer, single-consumer inbox drained at the start of every tick. +/// +/// 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. +/// internal sealed class GameCommandQueue { - private readonly ConcurrentQueue _commands = new(); + private readonly Channel _commands = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); - public void Enqueue(GameCommand command) => _commands.Enqueue(command); + public ChannelReader 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."); + } + } } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 82157f2..f4436d7 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -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; /// -/// Owns every 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 or its World. /// internal sealed class GameLoopService( IOptions options, GameCommandQueue commands, ClientRegistry clients, GameMetrics metrics, + SchoolStore store, + ILoggerFactory loggerFactory, ILogger logger) : BackgroundService { - /// Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped. - 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 _workers = []; + private readonly List _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; /// - /// 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. /// - public SchoolsState SchoolsState => Volatile.Read(ref _publishedState); + public SchoolsState SchoolsState + { + get + { + var workers = Volatile.Read(ref _publishedWorkers); + var schools = new SchoolState[workers.Length]; + for (var i = 0; i < workers.Length; i++) + { + schools[i] = workers[i].Snapshot; + } + + return new SchoolsState(_options.MaxSchools, schools); + } + } + + public Task ReloadFromDiskAsync(CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + commands.Enqueue(new GameCommand.ReloadSaves(completion)); + return completion.Task.WaitAsync(cancellationToken); + } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation( - "Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.", + "School supervisor starting at {TickRate} Hz, up to {MaxSchools} schools, saves in {Directory}.", _options.TickRate, _options.MaxSchools, - _options.GameMinutesPerRealSecond); + store.DirectoryPath); - using var timer = new PeriodicTimer(_options.TickInterval); - var fixedDelta = _options.FixedDeltaTime; - var lastTimestamp = Stopwatch.GetTimestamp(); - var accumulator = 0d; + await StartWorkersFromDiskAsync().ConfigureAwait(false); + _ = HeartbeatAsync(stoppingToken); try { - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + while (await commands.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false)) { - var now = Stopwatch.GetTimestamp(); - accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds; - lastTimestamp = now; - - DrainCommands(); - - var steps = 0; - while (accumulator >= fixedDelta && steps < MaxCatchUpSteps) + while (commands.Reader.TryRead(out var command)) { - var stepStarted = Stopwatch.GetTimestamp(); - _schools.Tick(); - Volatile.Write(ref _currentTick, _currentTick + 1); - metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds); - - accumulator -= fixedDelta; - steps++; - } - - if (steps == MaxCatchUpSteps && accumulator >= fixedDelta) - { - logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000); - accumulator = 0d; - } - - if (steps > 0) - { - PublishState(); - BroadcastClocks(); + await DispatchAsync(command).ConfigureAwait(false); } } } @@ -91,76 +84,128 @@ internal sealed class GameLoopService( } finally { - _schools.Dispose(); - logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick); + await StopAllWorkersAsync(persist: true).ConfigureAwait(false); + logger.LogInformation("School supervisor stopped at tick {Tick}.", CurrentTick); } } - private void DrainCommands() + private async Task HeartbeatAsync(CancellationToken cancellationToken) { - while (commands.TryDequeue(out var command)) + using var timer = new PeriodicTimer(_options.TickInterval); + + try { - switch (command) + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { - case GameCommand.CreateSchool create: - HandleCreate(create); - break; - - case GameCommand.DeleteSchool delete: - HandleDelete(delete); - break; - - case GameCommand.SuggestName suggest: - Complete(suggest.Result, () => _schools.SuggestName(suggest.Language)); - break; - - case GameCommand.OpenSchool open: - HandleOpen(open); - break; - - case GameCommand.CloseSchool close: - StopWatching(close.PlayerId, close.SchoolId); - break; - - case GameCommand.SetRunning setRunning: - WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running); - break; - - case GameCommand.SetSpeed setSpeed: - WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex); - break; + Interlocked.Increment(ref _currentTick); } } + catch (OperationCanceledException) + { + // Normal shutdown. + } } - private void HandleCreate(GameCommand.CreateSchool command) + private async Task DispatchAsync(GameCommand command) { - Complete(command.Result, () => + switch (command) { - var result = _schools.Create(command.Name, command.StartDate); - if (!result.Succeeded) - { - return new SchoolCreationOutcome(null, result.Error); - } + case GameCommand.CreateSchool create: + await HandleCreateAsync(create).ConfigureAwait(false); + break; - logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name); - PublishState(); + case GameCommand.DeleteSchool delete: + await HandleDeleteAsync(delete).ConfigureAwait(false); + break; - return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None); - }); + case GameCommand.SuggestName suggest: + Complete(suggest.Result, () => SuggestName(suggest.Language)); + break; + + case GameCommand.OpenSchool open: + HandleOpen(open); + break; + + case GameCommand.CloseSchool close: + StopWatching(close.PlayerId, close.SchoolId); + Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId)); + break; + + case GameCommand.SetRunning setRunning: + RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running)); + break; + + case GameCommand.SetSpeed setSpeed: + RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex)); + break; + + case GameCommand.ReloadSaves reload: + await HandleReloadAsync(reload).ConfigureAwait(false); + break; + } } - private void HandleDelete(GameCommand.DeleteSchool command) + private async Task HandleCreateAsync(GameCommand.CreateSchool command) { - Complete(command.Result, () => + try { - var deleted = _schools.Delete(command.SchoolId); - if (!deleted) + if (_workers.Count >= _options.MaxSchools) { - return false; + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached)); + return; } - // Anyone watching it now stares at a school that no longer exists. + if (!SchoolRegistry.TryNormalizeName(command.Name, out var normalized)) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName)); + return; + } + + if (!GameClock.IsValidStartDate(command.StartDate)) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate)); + return; + } + + var id = _nextId++; + store.WriteNextId(_nextId); + + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true); + Track(worker); + worker.Start(); + + try + { + await worker.Started.ConfigureAwait(false); + } + catch + { + Untrack(id); + await worker.StopAsync(persist: false).ConfigureAwait(false); + throw; + } + + logger.LogInformation("School {SchoolId} \"{Name}\" created.", id, normalized); + command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None)); + } + catch (Exception ex) + { + command.Result.TrySetException(ex); + } + } + + private async Task HandleDeleteAsync(GameCommand.DeleteSchool command) + { + try + { + if (!_workers.TryGetValue(command.SchoolId, out var worker)) + { + command.Result.TrySetResult(false); + return; + } + + Untrack(command.SchoolId); + foreach (var client in clients.All) { if (client.OpenSchoolId == command.SchoolId) @@ -170,11 +215,30 @@ internal sealed class GameLoopService( } } - logger.LogInformation("School {SchoolId} deleted.", command.SchoolId); - PublishState(); + await worker.StopAsync(persist: false).ConfigureAwait(false); + store.Delete(command.SchoolId); - return true; - }); + logger.LogInformation("School {SchoolId} deleted.", command.SchoolId); + command.Result.TrySetResult(true); + } + catch (Exception ex) + { + command.Result.TrySetException(ex); + } + } + + private async Task HandleReloadAsync(GameCommand.ReloadSaves command) + { + try + { + await StopAllWorkersAsync(persist: true).ConfigureAwait(false); + await StartWorkersFromDiskAsync().ConfigureAwait(false); + command.Result.TrySetResult(); + } + catch (Exception ex) + { + command.Result.TrySetException(ex); + } } private void HandleOpen(GameCommand.OpenSchool command) @@ -185,16 +249,15 @@ internal sealed class GameLoopService( return; } - var school = _schools.Find(command.SchoolId); - if (school is null) + if (!_workers.TryGetValue(command.SchoolId, out var worker)) { SendSchoolGone(client, command.SchoolId); return; } - client.OpenSchoolId = school.Id; - - logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id); + client.OpenSchoolId = command.SchoolId; + worker.Post(new WorkerCommand.Open(client)); + logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, command.SchoolId); } /// @@ -210,8 +273,21 @@ internal sealed class GameLoopService( } } - /// Applies a change to the school a connection has open, if it still has one. - private void WithOpenSchool(uint playerId, Action change) + private string SuggestName(SchoolNameLanguage language) + { + var taken = SchoolsState.Schools.Select(school => school.Name); + return _names.Next(taken, language); + } + + private void Route(int schoolId, WorkerCommand command) + { + if (_workers.TryGetValue(schoolId, out var worker)) + { + worker.Post(command); + } + } + + private void RouteOpenSchool(uint playerId, WorkerCommand command) { var client = clients.Find(playerId); if (client?.OpenSchoolId is not { } schoolId) @@ -219,60 +295,111 @@ internal sealed class GameLoopService( return; } - var school = _schools.Find(schoolId); - if (school is not null) - { - change(school); - } + Route(schoolId, command); } - private void BroadcastClocks() + private async Task StartWorkersFromDiskAsync() { - foreach (var client in clients.All) + var saves = store.LoadAll(); + var nextId = Math.Max(store.ReadNextId(), 1); + if (saves.Count > 0) { - if (!client.IsReady || client.OpenSchoolId is not { } schoolId) - { - continue; - } + nextId = Math.Max(nextId, saves.Max(save => save.Id) + 1); + } - var school = _schools.Find(schoolId); - if (school is null) - { - continue; - } + _nextId = nextId; - var frame = new byte[ProtocolCodec.MaxFrameSize]; - var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage( - school.Id, - new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(), - school.Clock.IsRunning, - (byte)school.Clock.SpeedIndex)); + if (saves.Count > _options.MaxSchools) + { + logger.LogWarning( + "Found {Count} school saves but the limit is {Max}; starting the first {Max}.", + saves.Count, + _options.MaxSchools, + _options.MaxSchools); + saves = [.. saves.Take(_options.MaxSchools)]; + } - client.TrySend(frame.AsMemory(0, length)); + foreach (var save in saves) + { + var worker = SpawnWorker( + save.Id, + save.Name, + save.GameTime, + save.Running, + save.SpeedIndex, + isNew: false); + Track(worker); + worker.Start(); + } + + if (_workers.Count > 0) + { + await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false); + logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count); } } - private void SendSchoolGone(GameClient client, int schoolId) + private async Task StopAllWorkersAsync(bool persist) + { + var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray(); + _workers.Clear(); + _order.Clear(); + PublishWorkers(); + + if (stopping.Length > 0) + { + await Task.WhenAll(stopping).ConfigureAwait(false); + } + } + + private SchoolWorker SpawnWorker(int id, string name, DateTime time, bool running, int speedIndex, bool isNew) => + new( + id, + name, + time, + running, + speedIndex, + isNew, + _options, + clients, + metrics, + store, + loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}")); + + private void Track(SchoolWorker worker) + { + _workers[worker.Id] = worker; + _order.Add(worker.Id); + PublishWorkers(); + } + + private void Untrack(int id) + { + _workers.Remove(id); + _order.Remove(id); + PublishWorkers(); + } + + private void PublishWorkers() + { + var snapshot = new SchoolWorker[_order.Count]; + for (var i = 0; i < _order.Count; i++) + { + snapshot[i] = _workers[_order[i]]; + } + + Volatile.Write(ref _publishedWorkers, snapshot); + metrics.SchoolsChanged(snapshot.Length); + } + + private static void SendSchoolGone(GameClient client, int schoolId) { var frame = new byte[ProtocolCodec.MaxFrameSize]; var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId)); client.TrySend(frame.AsMemory(0, length)); } - private void PublishState() - { - var snapshot = new SchoolsState( - _schools.MaxSchools, - _schools.Schools.Select(Capture).ToArray()); - - Volatile.Write(ref _publishedState, snapshot); - metrics.SchoolsChanged(snapshot.Schools.Count); - } - - private static SchoolState Capture(School school) => - new(school.Id, school.Name, school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex); - - /// Runs work for a waiting request thread without letting an exception kill the loop. + /// Runs work for a waiting request thread without letting an exception kill the supervisor. private static void Complete(TaskCompletionSource completion, Func work) { try diff --git a/src/HSchool.Server/Game/GameMetrics.cs b/src/HSchool.Server/Game/GameMetrics.cs index 46b8fcf..6f7d775 100644 --- a/src/HSchool.Server/Game/GameMetrics.cs +++ b/src/HSchool.Server/Game/GameMetrics.cs @@ -2,7 +2,7 @@ using System.Diagnostics.Metrics; namespace HSchool.Server.Game; -/// Game-loop counters surfaced in the Aspire dashboard. +/// Per-school tick counters surfaced in the Aspire dashboard. internal sealed class GameMetrics : IDisposable { public const string MeterName = "HSchool.Server.Game"; diff --git a/src/HSchool.Server/Game/SchoolCreationOutcome.cs b/src/HSchool.Server/Game/SchoolCreationOutcome.cs index 7028ea6..096c487 100644 --- a/src/HSchool.Server/Game/SchoolCreationOutcome.cs +++ b/src/HSchool.Server/Game/SchoolCreationOutcome.cs @@ -2,7 +2,7 @@ using HSchool.Simulation; namespace HSchool.Server.Game; -/// What the loop thread reports back after trying to create a school. +/// What the supervisor reports back after trying to create a school. internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error) { public bool Succeeded => Error == SchoolCreationError.None && School is not null; diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs index 9c5bde2..ae68a4a 100644 --- a/src/HSchool.Server/Game/SchoolState.cs +++ b/src/HSchool.Server/Game/SchoolState.cs @@ -2,7 +2,7 @@ namespace HSchool.Server.Game; /// /// Immutable copy of a school, safe to hand to request threads. The live School object -/// never leaves the loop thread. +/// never leaves its worker thread. /// internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs new file mode 100644 index 0000000..d1ba161 --- /dev/null +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using HSchool.Simulation; +using Microsoft.Extensions.Options; + +namespace HSchool.Server.Game; + +/// On-disk record of one school. Extra JSON fields are ignored so later slices can grow it. +internal sealed record SchoolSave(int Format, int Id, string Name, DateTime GameTime, bool Running, int SpeedIndex); + +/// Allocates school ids that survive a process restart. +internal sealed record SchoolSaveIndex(int NextId); + +/// +/// JSON files under . The worker of a school is the +/// only writer of that school's file; the supervisor reads the directory at start and on reload. +/// +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 _logger; + + public SchoolStore(IOptions options, IHostEnvironment environment, ILogger 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(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 LoadAll() + { + var saves = new List(); + + 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(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(string path, T value) + { + var json = JsonSerializer.Serialize(value, Json); + var temp = path + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs new file mode 100644 index 0000000..2f7825c --- /dev/null +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -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; + +/// +/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's +/// save file. Awaits are resolved with GetResult so stays on +/// this thread instead of hopping back onto the pool. +/// +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 _mailbox = Channel.CreateUnbounded( + 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; + + /// Last clock the worker published. Menu requests read this; the live school stays here. + 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; + } + } + + /// + /// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine; + /// then runs here, not as a pool callback. + /// + 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)); + } +} diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs new file mode 100644 index 0000000..d9a3e45 --- /dev/null +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -0,0 +1,18 @@ +using HSchool.Server.Net; + +namespace HSchool.Server.Game; + +/// +/// Work item for one school's worker. The supervisor never touches that school's World; +/// it only posts these. +/// +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; +} diff --git a/src/HSchool.Server/Net/GameClient.cs b/src/HSchool.Server/Net/GameClient.cs index a5606c8..9909bc3 100644 --- a/src/HSchool.Server/Net/GameClient.cs +++ b/src/HSchool.Server/Net/GameClient.cs @@ -5,8 +5,8 @@ namespace HSchool.Server.Net; /// /// 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. /// 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); /// - /// School this connection is watching, or null in the menu. Written by the loop thread, - /// read by the connection thread on disconnect. + /// School this connection is watching, or null in the menu. Written by the supervisor + /// on open/close, read by the connection thread on disconnect. /// public int? OpenSchoolId { diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index f13a933..e9c6fe8 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -8,7 +8,7 @@ namespace HSchool.Server.Net; /// /// 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. /// internal sealed class GameSocketHandler( ClientRegistry clients, diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index e298bd8..2f32408 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); @@ -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) => { diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index f935ddb..b0aaa9f 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -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 } } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index c082c56..d264e63 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -22,13 +22,25 @@ public sealed class School : IDisposable World = World.Create(); } + /// A brand-new school: calendar running at the start date, empty world. + public static School Create(int id, string name, DateTime startDate) => new(id, name, startDate); + + /// Rebuilds a school from a save. Time, pause and speed come from disk, not defaults. + 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; } - /// The Arch world backing this school. Only the loop thread may touch it. + /// The Arch world backing this school. Only this school's worker thread may touch it. public World World { get; } /// Runs one fixed step of the school. Today that is only the calendar. diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs index c65d38a..a55d5fd 100644 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ b/src/HSchool.Simulation/SchoolRegistry.cs @@ -18,8 +18,8 @@ public readonly record struct SchoolCreationResult(School? School, SchoolCreatio } /// -/// 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. /// 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); diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index 1bf46d5..a0adc7a 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -26,8 +26,21 @@ public sealed class SimulationOptions set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc); } + /// + /// Directory for per-school save files. Relative paths are resolved against the content root. + /// + public string SavesDirectory { get; set; } = "saves"; + + /// + /// How often a running school writes its clock to disk. Create, pause, speed and shutdown + /// write immediately; the tick itself never does. + /// + public int SaveIntervalSeconds { get; set; } = 30; + /// Length of one fixed step. public double FixedDeltaTime => 1d / TickRate; public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate); + + public TimeSpan SaveInterval => TimeSpan.FromSeconds(SaveIntervalSeconds); } diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index f501b9a..ee2b61c 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -5,7 +5,6 @@ namespace HSchool.AppHost.Tests; /// /// 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. /// [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() { diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 2104601..d6b8335 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -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); diff --git a/tests/HSchool.Simulation.Tests/SchoolTests.cs b/tests/HSchool.Simulation.Tests/SchoolTests.cs new file mode 100644 index 0000000..0fe9175 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/SchoolTests.cs @@ -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); + } +}