Files
h-school/docs/architecture.md
T

108 lines
6.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 20 Hz │ WebSocket /ws/game │ │ │
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
│ │ ├── SchoolRegistry │ └──────────────────┘ │
│ │ │ └── School │ │
│ │ │ ├─ Clock│ │
│ │ │ └─ World│ (Arch ECS, empty for now) │
│ │ └── 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.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.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`. Nothing in `Simulation` 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 tick
`GameLoopService` wakes on a `PeriodicTimer` at the configured rate (20 Hz by default) and, for
each wake-up:
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.
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.
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
touching a school itself.
## Schools
A `School` is one save: an id, a name, a `GameClock` and an Arch `World`. The world is empty
today — pupils, rooms and staff land in it as the game grows — but it is created and destroyed
with the school so ownership is never in question.
`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
nothing more.
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.
## 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.
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.
## 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.
- **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks
into create/delete plus a periodic snapshot from the loop thread.