108 lines
6.8 KiB
Markdown
108 lines
6.8 KiB
Markdown
# 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.
|