- Introduced the `HSchool.Ai` project, responsible for routing, day plans, and presence management. - Updated the `HSchool.Simulation` project to integrate with the new AI functionalities, improving decision-making and presence tracking. - Added `travelMinutes` to room and territory definitions, ensuring accurate movement calculations within the simulation. - Enhanced the `School` class to manage presence and implement empty-time skipping functionality. - Updated documentation to reflect the new AI features and their impact on school simulation. - Added tests for presence management and routing to ensure robust functionality and reliability.
138 lines
9.1 KiB
Markdown
138 lines
9.1 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│ │
|
||
│ │ │ ├─ 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.Ai` | Walk graph, day plans, duty rooms. No Arch, no ASP.NET, no `DateTime.Now`. |
|
||
| `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 → Ai → People / 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. Presence is a component:
|
||
each person occupies a map node or is off campus. Empty-time skip jumps the clock to the next
|
||
work morning when the campus is empty and the day frame is closed.
|
||
|
||
`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, the map layout and each person's place on that map; 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.
|