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.
This commit is contained in:
+37
-24
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) → школа на месте с тем же временем с разумной погрешностью снимка
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -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, снимок при открытии |
|
||||
|
||||
Reference in New Issue
Block a user