Files
h-school/docs/architecture.md
T
Leonid Pershin 2011d12b1d
ci / server (push) Failing after 3m33s
ci / client (push) Successful in 13s
Add timetable planning and related tests to school simulation
- Introduced a new project for timetable planning, dependent on HSchool.Content.
- Updated documentation to reflect the addition of timetable planning and its dependencies.
- Added tests for timetable planning to ensure deterministic behavior with the same staff and map.
- Revised architecture documentation to include the new HSchool.Schedule component and its interactions.
2026-08-19 00:55:02 +03:00

135 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture
The server owns the schools; the browser draws them. There is no game logic on the client, and
there is no UI on the server.
```
┌───────────────────────────── Aspire AppHost ─────────────────────────────┐
│ │
│ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │
│ │ │ │ (Vite + DOM) │ │
│ │ GameLoopService │ WebSocket /ws/game │ │ │
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
│ │ │ └── School │ │
│ │ │ ├─ Clock│ │
│ │ │ ├─ Catalog (frozen Content) │
│ │ │ ├─ Map │
│ │ │ ├─ Roster │
│ │ │ └─ World│ (Arch ECS: people, classes) │
│ │ ├── SchoolStore │ saves/{id}.json + {id}.people.json │
│ │ ├── ModContent │ mods/<id>/ │
│ │ └── ClientRegistry │ │
│ └────────────────────────┘ │
│ │ OTLP logs / traces / metrics │
│ ▼ │
│ Aspire dashboard │
└──────────────────────────────────────────────────────────────────────────┘
```
## Projects
| Project | Role |
| --- | --- |
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
| `src/HSchool.People` | Roster generation from catalog, map and seed. No Arch, no ASP.NET. |
| `src/HSchool.Schedule` | Timetable from curriculum, assignments and map. No Arch, no ASP.NET. |
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `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. |
Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
`Protocol` knows about schools.
## Two channels, on purpose
The menu is request/response — you list, create and delete saves — so it is plain REST over JSON.
The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both
are described in [`protocol.md`](protocol.md).
## The supervisor and the workers
`GameLoopService` is a thin supervisor. It does not tick calendars and it does not touch a
`World`. It:
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 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 supervisor completes. That is how a POST gets its answer without ever
touching a school itself.
## Schools
A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an
Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet.
`GameClock` moves while it is running, in fixed steps:
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
per real second at ×1, with ×½, ×2, ×3 and ×4 as the other stops. The same number of ticks always
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
sends one map snapshot labelled in the Hello locale. 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, and `saves/{id}.people.json` for the roster). The worker writes the clock file on
create, on shutdown, and on a rare clock snapshot (`SaveIntervalSeconds`, 30 by default) — never
on every tick. The people file is written only when composition changes (create, and later yearly
intake). Pause and speed changes are written
too, but coalesced to at most one write per `MinSaveIntervalMilliseconds`: a client can send those
as fast as the socket allows, and each one is a file write on the school's own thread. Shutdown
always flushes, so a pause is never lost. The clock file also stores the
mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing
mod folder, a map that no longer validates, or a roster that no longer fits the map leaves the
files in place and that school unstarted.
## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello` (version + UI locale); 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`; the worker sends a map snapshot then 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.
Clock 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 a worker. The map
snapshot uses a separate reliable queue so it cannot be dropped for a newer tick.
## Where to add things next
- **Something inside a school**: add components and systems around `School.World`, run them from
`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.
- **Create editor and the map snapshot**: done in this slice. Next game verbs (Sit) and the
event log are out of scope here.