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
+37 -24
View File
@@ -9,12 +9,13 @@ there is no UI on the server.
│ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │
│ │ │ │ (Vite + DOM) │ │
│ │ GameLoopService 20 Hz │ WebSocket /ws/game │ │ │
│ │ GameLoopService │ WebSocket /ws/game │ │ │
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
│ │ ├── SchoolRegistry │ └──────────────────┘ │
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
│ │ │ └── School │ │
│ │ │ ├─ Clock│ │
│ │ │ └─ World│ (Arch ECS, empty for now) │
│ │ ├── SchoolStore │ saves/{id}.json │
│ │ └── ClientRegistry │ │
│ └────────────────────────┘ │
│ │ OTLP logs / traces / metrics │
@@ -29,7 +30,7 @@ there is no UI on the server.
| --- | --- |
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. No ASP.NET, no sockets — this is what unit tests exercise. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, the loop that drives the schools. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, disk saves. |
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
@@ -43,26 +44,29 @@ The menu is request/response — you list, create and delete saves — so it is
The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both
are described in [`protocol.md`](protocol.md).
## The tick
## The supervisor and the workers
`GameLoopService` wakes on a `PeriodicTimer` at the configured rate (20 Hz by default) and, for
each wake-up:
`GameLoopService` is a thin supervisor. It does not tick calendars and it does not touch a
`World`. It:
1. **Drains the command queue.** Create, delete, open, close and clock changes all arrive from
request or connection threads as `GameCommand` records. This is the only way anything mutates a
school.
2. **Advances every running school** by a fixed delta (`1 / TickRate`), catching up at most 5 steps
if the host stalled; a longer backlog is dropped with a warning.
3. **Publishes the menu state** an immutable `SchoolsState` the HTTP handlers read without
blocking — and **pushes a clock frame** to every connection that has a school open.
1. **Drains `GameCommandQueue`.** Create, delete and name suggestions stay here. Open, close,
running and speed are forwarded to that school's mailbox.
2. **Owns the table of workers.** Each school is a `SchoolWorker` on a dedicated
`LongRunning` thread with its own `PeriodicTimer`, its own `School` (clock + world) and its
own save file.
3. **Exposes menu state.** Each worker publishes an immutable `SchoolState`; HTTP handlers read
those snapshots without blocking the worker.
The registry is single-threaded on purpose: only the loop thread touches `SchoolRegistry` or any
`School`. Everything else communicates through `GameCommandQueue` (inbound), the published state
(menu reads) and per-client outboxes (outbound). If you find yourself wanting a lock, you are
probably about to break it.
The worker advances its school by a fixed delta (`1 / TickRate`), catching up at most 5 steps if
it stalled; a longer backlog is dropped with a warning. Clock frames go from that worker into the
outboxes of connections that have this school open.
Only that worker thread touches its `School` or `World`. Everything else communicates through
mailboxes (inbound), published snapshots (menu reads) and per-client outboxes (outbound). If you
find yourself wanting a lock, you are probably about to break it.
Commands that a request must wait for — create, delete, name suggestion — carry a
`TaskCompletionSource` the loop thread completes. That is how a POST gets its answer without ever
`TaskCompletionSource` the supervisor completes. That is how a POST gets its answer without ever
touching a school itself.
## Schools
@@ -79,23 +83,31 @@ produces the same date.
**Every school runs on its own.** A new school starts living immediately and keeps going whether
or not anybody is looking at it; only the player's pause button stops one, and that pause sticks
until they press play again. Opening a school subscribes the connection to its clock frames and
nothing more.
nothing more. One school's pause cannot stall another's calendar, because they do not share a
thread.
The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is
how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh
cannot land between a mouse-down and a click.
## Saves
Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json`
for the next id). The worker writes on create, pause, speed change, shutdown, and on a rare clock
snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The supervisor reloads the
directory at process start. A file that cannot be read is left in place and logged.
## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello`; a version mismatch closes the socket.
3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready.
4. Opening a school enqueues `OpenSchool`; from the next tick on, clock frames arrive.
5. `SetRunning` and `SetSpeed` drive the calendar; `CloseSchool` goes back to the menu.
4. Opening a school enqueues `OpenSchool`; the worker starts pushing clock frames.
5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu.
6. On disconnect the client is removed; the school it was watching keeps running.
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate clock frames instead of stalling the loop.
that cannot keep up loses intermediate clock frames instead of stalling a worker.
## Where to add things next
@@ -103,5 +115,6 @@ that cannot keep up loses intermediate clock frames instead of stalling the loop
`School.Tick`, and unit-test them against `School` directly — no server needed.
- **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from
the server after every change, so nothing else has to know.
- **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks
into create/delete plus a periodic snapshot from the loop thread.
- **Defs, map, mods**: the save record is intentionally small so later slices can add layout and
pack ids without a second persistence mechanism. That work lives in
[`phases/03-defs-map.md`](phases/03-defs-map.md).