121 lines
7.5 KiB
Markdown
121 lines
7.5 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 │ WebSocket /ws/game │ │ │
|
||
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
|
||
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
|
||
│ │ │ └── School │ │
|
||
│ │ │ ├─ Clock│ │
|
||
│ │ │ └─ World│ (Arch ECS, empty for now) │
|
||
│ │ ├── SchoolStore │ saves/{id}.json │
|
||
│ │ └── 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, 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. |
|
||
|
||
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 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` 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. 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`; 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 a worker.
|
||
|
||
## 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.
|
||
- **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).
|