`'s `close` event is not delivered by every engine** (Chromium 148 fires only
+ `toggle`). `ui/modal.ts` resolves its promise explicitly on every exit for that reason — never go
+ back to awaiting the event, or a dialog will silently freeze the screen that awaited it.
+- **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration
+ arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own
+ time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason.
- `PeriodicTimer` does not catch up on its own. The accumulator in `GameLoopService` does, capped
at 5 steps — do not "simplify" it away.
-- Arch recycles entity ids. Replicate `NetworkId`, never `Entity.Id`.
-- Snapshots are full-state: an entity missing from a frame is *despawned* by the client. Filtering
- entities out of a snapshot is how you accidentally delete them on screen.
-- The client outbox drops the oldest frame under pressure. That is correct for snapshots and wrong
- for anything that must arrive exactly once — such a message would need its own path.
+- Every school runs whether or not a connection is watching it, so anything you hang off the tick
+ runs six times over once six schools exist. `OpenSchool` only subscribes to clock frames.
+- The menu polls `GET /api/schools` once a second and patches its cards in place. Rebuilding the
+ list on every refresh would drop focus and swallow clicks.
+- The client outbox drops the oldest frame under pressure. That is correct for clock frames and
+ wrong for anything that must arrive exactly once — such a message would need its own path.
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
are used throughout.
diff --git a/HSchool.slnx b/HSchool.slnx
deleted file mode 100644
index 4e3a243..0000000
--- a/HSchool.slnx
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/README.md b/README.md
index 171cf09..1a7a924 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
# h-school
-Base for a multiplayer browser game: an authoritative .NET server simulating the world with an
-ECS, a PixiJS client that renders snapshots, and .NET Aspire tying them together for local runs
-and integration tests.
+Base for a school-management game: an authoritative .NET server that owns the saves and their
+in-game calendars, a TypeScript client that renders the menus, and .NET Aspire tying them together
+for local runs and integration tests.
-There is no game here yet — there is a world with a few obstacles, players that can walk around
-it, and every piece of plumbing needed to build a game on top.
+What exists today is the shell around a game: a main menu of schools, a creation form, and a
+school screen with a running game clock. The school itself is still empty.
## Stack
@@ -13,8 +13,8 @@ it, and every piece of plumbing needed to build a game on top.
| --- | --- |
| Server | .NET 10, ASP.NET Core |
| Simulation | [Arch](https://github.com/genaray/Arch) ECS, fixed 20 Hz tick |
-| Transport | raw WebSocket, custom binary protocol |
-| Client | TypeScript, [PixiJS 8](https://pixijs.com/), Vite |
+| Transport | REST for the menu, raw WebSocket + binary protocol for the clock |
+| Client | TypeScript, Vite, plain DOM ([PixiJS 8](https://pixijs.com/) is installed for the game view that comes next) |
| Orchestration | .NET Aspire 13 |
| Tests | xUnit v3, Vitest, `Aspire.Hosting.Testing` |
@@ -36,10 +36,22 @@ and Node are on PATH first and passes any arguments through
The Aspire dashboard opens with two resources: `server` (ASP.NET Core) and `client` (Vite dev
server). Aspire assigns the client a random port on every run, so take its URL from the dashboard
-rather than guessing. Open it and use **WASD** or the arrow keys to move — the HUD shows
-connection state, server tick, round-trip time and entity count.
+rather than guessing.
-Open the same URL in a second tab to see another player: both are simulated by the one server.
+## What you can do
+
+- **Main menu** — every school as a card with its name and its current in-game date and time,
+ ticking live. Deleting one asks for confirmation first.
+- **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00
+ by default). At six schools the create button is disabled and says why.
+- **Inside a school** — the date, time and weekday of the game calendar, play/pause and the
+ ×½ ×1 ×2 ×3 ×4 speed buttons, plus a way back to the menu.
+
+Time runs at 5 game minutes per real second at ×1. **Every school runs on its own**, whether or
+not you are inside it — the menu cards keep counting. Only the pause button stops a school, and it
+stays paused (the card says so) until you press play again.
+
+Schools live in server memory: restarting the server clears them.
## Run the pieces separately
@@ -61,30 +73,30 @@ dotnet test
```
- `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts.
-- `tests/HSchool.Simulation.Tests` — ECS behaviour against `GameWorld`, no host involved.
-- `tests/HSchool.AppHost.Tests` — boots the real Aspire graph, connects a WebSocket, plays a few
- ticks. Runs headless (`--HSchool:Headless=true`), so no Node install is needed.
+- `tests/HSchool.Simulation.Tests` — the game clock and the school registry, no host involved.
+- `tests/HSchool.AppHost.Tests` — boots the real Aspire graph, drives the menu API and the
+ WebSocket clock. Runs headless (`--HSchool:Headless=true`), so no Node install is needed.
```bash
npm --prefix src/HSchool.Client test
```
-Vitest covers the client codec and snapshot interpolation.
+Vitest covers the client codec and the calendar formatting.
## Layout
```
src/
HSchool.Protocol/ binary wire format (shared contract with the client)
- HSchool.Simulation/ Arch ECS world, components, systems
- HSchool.Server/ ASP.NET Core host, WebSocket endpoint, game loop
+ HSchool.Simulation/ schools, the game clock, the Arch ECS world
+ HSchool.Server/ ASP.NET Core host, menu API, WebSocket endpoint, game loop
HSchool.ServiceDefaults/ Aspire telemetry, health checks, resilience
HSchool.AppHost/ Aspire orchestration
- HSchool.Client/ Vite + TypeScript + PixiJS renderer
+ HSchool.Client/ Vite + TypeScript UI
tests/
docs/
architecture.md how the pieces fit together
- protocol.md the wire format, byte by byte
+ protocol.md the HTTP API and the wire format, byte by byte
AGENTS.md working agreements for humans and coding agents
```
@@ -96,12 +108,12 @@ Simulation tunables live under the `Simulation` section of
| Key | Default | Meaning |
| --- | --- | --- |
| `TickRate` | 20 | fixed simulation steps per second |
-| `WorldWidth` / `WorldHeight` | 1600 × 900 | field size in simulation units |
-| `PlayerSpeed` | 260 | units per second |
-| `PlayerRadius` | 18 | player body radius |
+| `MaxSchools` | 6 | how many schools may exist at once |
+| `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 |
+| `DefaultStartDate` | `2012-04-03T06:00:00` | prefilled start of a new school |
## What is deliberately missing
-No authentication, no persistence, no client-side prediction, no delta compression, no rooms or
-matchmaking. Each of these has a natural seam described in
+No persistence, no authentication, and nothing inside a school yet — every school owns an empty
+ECS world waiting for its first entities. Each of these has a seam described in
[`docs/architecture.md`](docs/architecture.md).
diff --git a/docs/architecture.md b/docs/architecture.md
index 8d6c338..a5e146b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,97 +1,107 @@
-# Architecture
-
-The server owns the world; the browser draws it. There is no game logic on the client, and there
-is no rendering on the server.
-
-```
-┌───────────────────────────── Aspire AppHost ─────────────────────────────┐
-│ │
-│ ┌────────────────────────┐ WebSocket /ws/game ┌──────────────────┐ │
-│ │ HSchool.Server │ ◄────── binary ──────► │ HSchool.Client │ │
-│ │ │ │ (Vite + Pixi) │ │
-│ │ GameLoopService 20 Hz │ HTTP /api, /health └──────────────────┘ │
-│ │ ├── GameCommandQueue│ │
-│ │ ├── GameWorld (Arch)│ │
-│ │ └── 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 network. |
-| `src/HSchool.Simulation` | Arch ECS world, components, systems, fixed-step pipeline. No ASP.NET, no sockets — this is what unit tests exercise. |
-| `src/HSchool.Server` | ASP.NET Core host: WebSocket endpoint, connection lifetime, the loop that drives the simulation. |
-| `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 + PixiJS renderer. |
-
-Dependency direction is one-way: `Protocol ← Simulation ← Server ← AppHost`. Nothing in
-`Simulation` knows about HTTP, and nothing in `Protocol` knows about ECS.
-
-## 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.** Join, leave and input all arrive from connection threads as
- `GameCommand` records. This is the only way anything mutates the world.
-2. **Steps the simulation** with a fixed delta (`1 / TickRate`), catching up at most 5 steps if the
- host stalled; a longer backlog is dropped with a warning rather than simulated in a burst.
-3. **Captures and broadcasts a snapshot.** One immutable buffer is shared by every connection.
-
-`GameWorld` is single-threaded on purpose: only the loop thread touches the Arch `World`.
-Everything else communicates through `GameCommandQueue` (inbound) and per-client outboxes
-(outbound). That is the whole concurrency model — if you find yourself wanting a lock, you are
-probably about to break it.
-
-## ECS layout
-
-Components are plain mutable structs in `HSchool.Simulation/Components`:
-
-- `Position`, `Velocity` — movement state.
-- `PlayerControl` — the latest input mask plus its sequence number and the owner's player id.
-- `Renderable` — kind, radius and colour; replicated verbatim to the client.
-- `NetworkId` — stable replication id, because Arch recycles entity ids.
-
-Systems implement `ISimulationSystem` and run in registration order:
-`PlayerInputSystem` (intent → velocity) → `MovementSystem` (velocity → position) →
-`WorldBoundsSystem` (clamp to the field). Adding a system means adding it to the array in
-`GameWorld`'s constructor — order is explicit, not discovered.
-
-## Connection lifetime
-
-1. The browser opens `/ws/game`; `ClientRegistry` assigns a player id.
-2. The client sends `Hello`; a version mismatch closes the socket.
-3. The handler enqueues a `Join` command and waits for the loop thread to spawn the avatar.
-4. The `Welcome` frame goes out, the client is marked ready, and only then does it start
- receiving snapshots — so world state never arrives before the client knows its own entity id.
-5. The receive loop turns `Input` into commands and answers `Ping` directly.
-6. On disconnect the client is removed from the registry and a `Leave` command despawns the avatar.
-
-Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
-that cannot keep up loses intermediate snapshots instead of stalling the loop.
-
-## Rendering
-
-The client buffers snapshots and renders ~100 ms in the past (`SnapshotBuffer`), interpolating
-between the two frames that straddle the render time. That is what turns 20 discrete server ticks
-into smooth motion at display refresh rate, at the cost of a fixed visual delay.
-
-`WorldRenderer` keeps one PixiJS `Graphics` per replication id, creates it on first sight and
-destroys it when the id disappears from a snapshot. The field is scaled to fit the viewport with
-letterboxing, so every player sees the same area regardless of window size.
-
-## Where to add things next
-
-- **New replicated component**: add the struct, extend `GameWorld.CaptureSnapshot`, extend the
- snapshot layout in [`protocol.md`](protocol.md) and both codecs, bump the protocol version.
-- **New system**: implement `ISimulationSystem`, register it in `GameWorld`, unit-test it against
- `GameWorld` directly — no server needed.
-- **Client-side prediction**: the input `sequence` already travels to the server; echo the last
- processed sequence back in snapshots, then replay unacknowledged inputs on the client.
+# 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.
diff --git a/docs/protocol.md b/docs/protocol.md
index f3e52b8..7def5f6 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -1,123 +1,193 @@
-# Wire protocol v1
-
-Binary frames over a single WebSocket at `/ws/game`. One protocol message per frame, no
-framing header beyond the message id. **All multi-byte numbers are little-endian.**
-
-Three files must stay in sync — change them in the same commit:
-
-| Where | File |
-| --- | --- |
-| Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) |
-| Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) |
-| This document | `docs/protocol.md` |
-
-Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server
-closes connections whose hello carries a different version with `1002 ProtocolError`.
-
-## Message ids
-
-Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
-frame is obvious at a glance.
-
-| Id | Direction | Message |
-| --- | --- | --- |
-| `0x01` | C → S | Hello |
-| `0x02` | C → S | Input |
-| `0x03` | C → S | Ping |
-| `0x81` | S → C | Welcome |
-| `0x82` | S → C | Snapshot |
-| `0x83` | S → C | Pong |
-
-## Client → server
-
-### `0x01` Hello
-
-Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x01` |
-| 1 | `u8` | protocol version |
-| 2 | `u8` | name length in bytes (≤ 32) |
-| 3 | `u8[]` | UTF-8 name |
-
-### `0x02` Input
-
-Sent at ~30 Hz whether or not the mask changed.
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x02` |
-| 1 | `u32` | sequence number, monotonically increasing |
-| 5 | `u8` | button mask |
-
-Button mask: `1` up, `2` down, `4` left, `8` right. Frames with a sequence lower than the last
-accepted one are ignored, so a late packet cannot undo a newer intent.
-
-### `0x03` Ping
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x03` |
-| 1 | `i64` | client clock in milliseconds |
-
-## Server → client
-
-### `0x81` Welcome — 15 bytes
-
-The first frame the client receives; no snapshot is queued before it.
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x81` |
-| 1 | `u8` | protocol version |
-| 2 | `u32` | replication id of this client's own avatar |
-| 6 | `u8` | tick rate in Hz |
-| 7 | `f32` | world width |
-| 11 | `f32` | world height |
-
-### `0x82` Snapshot — 7 + 21·N bytes
-
-Full state, no delta compression yet. **Entities missing from a snapshot are despawned by the
-client**, which is why every visible entity is present in every frame.
-
-Header:
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x82` |
-| 1 | `u32` | tick |
-| 5 | `u16` | entity count |
-
-Then, per entity (21 bytes):
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| +0 | `u32` | replication id (never reused within a session) |
-| +4 | `u8` | kind: `0` unknown, `1` player, `2` obstacle |
-| +5 | `f32` | x |
-| +9 | `f32` | y |
-| +13 | `f32` | radius |
-| +17 | `u32` | colour, packed `0x00RRGGBB` |
-
-### `0x83` Pong — 13 bytes
-
-| Offset | Type | Field |
-| --- | --- | --- |
-| 0 | `u8` | `0x83` |
-| 1 | `i64` | client clock, echoed unchanged |
-| 9 | `u32` | server tick when the ping was handled |
-
-## Guarantees and limits
-
-- Frames larger than 64 KiB are refused with close status `1009 MessageTooBig`.
-- A malformed frame closes the connection with `1007 InvalidPayloadData`.
-- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
- older clients within the same protocol version.
-- Snapshot delivery is lossy under back pressure: each connection buffers 32 frames and drops the
- oldest, because a stale snapshot is worthless once a newer one exists.
-
-## Not in v1 yet
-
-Client-side prediction and reconciliation (the `sequence` field exists for it but is never echoed
-back), delta compression, interest management, and any form of authentication.
+# Wire protocol v3
+
+The client talks to the server two ways:
+
+- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are
+ request/response by nature, so they are plain REST.
+- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second.
+
+This document covers both. One protocol message per WebSocket frame, no framing header beyond the
+message id. **All multi-byte numbers are little-endian.**
+
+Three files must stay in sync — change them in the same commit:
+
+| Where | File |
+| --- | --- |
+| Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) |
+| Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) |
+| This document | `docs/protocol.md` |
+
+Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server
+closes connections whose hello carries a different version with `1002 ProtocolError`.
+
+## HTTP API
+
+Game dates are ISO-8601 UTC instants. The in-game calendar has no time zone — UTC is only used so
+the wire format is unambiguous, and the client formats it back in UTC.
+
+### `GET /api/schools`
+
+Everything the main menu needs in one request.
+
+```json
+{
+ "maxSchools": 6,
+ "defaultStartDate": "2012-04-03T06:00:00Z",
+ "gameMinutesPerRealSecond": 5,
+ "schools": [
+ { "id": 1, "name": "Гимназия №14", "gameTime": "2012-04-03T07:35:00Z", "running": false, "speedIndex": 1 }
+ ]
+}
+```
+
+### `GET /api/schools/random-name`
+
+`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
+
+### `POST /api/schools`
+
+Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
+
+| Status | Meaning |
+| --- | --- |
+| `201` | Created; body is the school. |
+| `400` `invalid-name` | Blank, or longer than 40 characters. |
+| `400` `invalid-start-date` | Outside 1900–2999. |
+| `409` `school-limit-reached` | `maxSchools` schools already exist. |
+
+Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
+
+### `DELETE /api/schools/{id}`
+
+`204` when deleted, `404` when the id is unknown. Anyone watching that school over a WebSocket
+gets a `SchoolGone` frame.
+
+## WebSocket message ids
+
+Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
+frame is obvious at a glance.
+
+| Id | Direction | Message |
+| --- | --- | --- |
+| `0x01` | C → S | Hello |
+| `0x02` | C → S | Ping |
+| `0x03` | C → S | OpenSchool |
+| `0x04` | C → S | CloseSchool |
+| `0x05` | C → S | SetRunning |
+| `0x06` | C → S | SetSpeed |
+| `0x81` | S → C | Welcome |
+| `0x82` | S → C | Pong |
+| `0x83` | S → C | Clock |
+| `0x84` | S → C | SchoolGone |
+
+## Client → server
+
+### `0x01` Hello — 2 bytes
+
+Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x01` |
+| 1 | `u8` | protocol version |
+
+### `0x02` Ping — 9 bytes
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x02` |
+| 1 | `i64` | client clock in milliseconds |
+
+### `0x03` OpenSchool — 5 bytes
+
+Starts watching a school: clock frames for it begin to arrive. It does not start the calendar —
+every school runs on its own from the moment it is created.
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x03` |
+| 1 | `i32` | school id |
+
+### `0x04` CloseSchool — 1 byte
+
+Back to the menu: the clock frames stop. The school keeps running — only `SetRunning` pauses it,
+and that pause survives leaving and reconnecting.
+
+### `0x05` SetRunning — 2 bytes
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x05` |
+| 1 | `u8` | `1` running, `0` paused |
+
+### `0x06` SetSpeed — 2 bytes
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x06` |
+| 1 | `u8` | speed index |
+
+Running and speed are **separate messages on purpose**. A single "set clock" message forces each
+button to resend the other field from the client's own copy of the state, which is always at least
+one tick stale — pressing play and then a speed button would pause the school again.
+
+Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of-range values are
+ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game
+minutes per real second.
+
+## Server → client
+
+### `0x81` Welcome — 4 bytes
+
+The first frame the client receives.
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x81` |
+| 1 | `u8` | protocol version |
+| 2 | `u8` | tick rate in Hz |
+| 3 | `u8` | maximum number of schools |
+
+### `0x82` Pong — 13 bytes
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x82` |
+| 1 | `i64` | client clock, echoed unchanged |
+| 9 | `u32` | server tick when the ping was handled |
+
+### `0x83` Clock — 15 bytes
+
+Sent every tick to every connection that has a school open, and only to those.
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x83` |
+| 1 | `i32` | school id |
+| 5 | `i64` | in-game date, milliseconds since the Unix epoch, read as UTC |
+| 13 | `u8` | `1` running, `0` paused |
+| 14 | `u8` | speed index |
+
+### `0x84` SchoolGone — 5 bytes
+
+The open school no longer exists — deleted from the menu in another tab, or never existed. The
+client returns to the menu.
+
+| Offset | Type | Field |
+| --- | --- | --- |
+| 0 | `u8` | `0x84` |
+| 1 | `i32` | school id |
+
+## Guarantees and limits
+
+- Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`.
+- A malformed frame closes the connection with `1007 InvalidPayloadData`.
+- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
+ older clients within the same protocol version.
+- Clock delivery is lossy under back pressure: each connection buffers 32 frames and drops the
+ oldest, because a stale clock is worthless once a newer one exists.
+
+## Not in v3 yet
+
+Saving schools to disk (they live in server memory), authentication, and any game state beyond the
+calendar — the school's ECS world is created but still empty.
diff --git a/src/HSchool.Client/index.html b/src/HSchool.Client/index.html
index fd5dca6..e2d4646 100644
--- a/src/HSchool.Client/index.html
+++ b/src/HSchool.Client/index.html
@@ -1,5 +1,5 @@
-
+
@@ -7,13 +7,11 @@
-
-
- connecting…
- tick 0
- -- ms
- 0 entities
-
+
+
diff --git a/src/HSchool.Client/src/format/gameTime.test.ts b/src/HSchool.Client/src/format/gameTime.test.ts
new file mode 100644
index 0000000..7f4f51e
--- /dev/null
+++ b/src/HSchool.Client/src/format/gameTime.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import {
+ formatGameDate,
+ formatGameDateTime,
+ formatGameTimeOfDay,
+ formatGameWeekday,
+ fromDateAndTimeInputs,
+ toDateAndTimeInputs,
+} from './gameTime.ts';
+
+// The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
+const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
+
+describe('game time formatting', () => {
+ it('shows the time of day in 24-hour form', () => {
+ expect(formatGameTimeOfDay(START)).toBe('06:00');
+ });
+
+ it('shows the weekday of the game date', () => {
+ expect(formatGameWeekday(START)).toBe('вторник');
+ });
+
+ it('shows the full date', () => {
+ expect(formatGameDate(START)).toContain('2012');
+ expect(formatGameDate(START)).toContain('апреля');
+ });
+
+ it('shows a compact date and time for the school cards', () => {
+ expect(formatGameDateTime(START)).toContain('03.04.2012');
+ expect(formatGameDateTime(START)).toContain('06:00');
+ });
+
+ it('reads the calendar in UTC, so the school day does not shift with the viewer', () => {
+ // Just before midnight UTC: any local-time formatting would land on the 4th.
+ const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0));
+
+ expect(formatGameDateTime(lateEvening)).toContain('03.04.2012');
+ expect(formatGameTimeOfDay(lateEvening)).toBe('23:30');
+ });
+});
+
+describe('date inputs', () => {
+ it('splits an instant into the date and time input values', () => {
+ expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-04-03', time: '06:00' });
+ });
+
+ it('rebuilds the same instant from those values', () => {
+ const { date, time } = toDateAndTimeInputs(START);
+
+ expect(fromDateAndTimeInputs(date, time)).toEqual(START);
+ });
+
+ it('returns null when a field is empty', () => {
+ expect(fromDateAndTimeInputs('', '06:00')).toBeNull();
+ expect(fromDateAndTimeInputs('2012-04-03', '')).toBeNull();
+ });
+
+ it('returns null for an unparsable date', () => {
+ expect(fromDateAndTimeInputs('not-a-date', '06:00')).toBeNull();
+ });
+});
diff --git a/src/HSchool.Client/src/format/gameTime.ts b/src/HSchool.Client/src/format/gameTime.ts
new file mode 100644
index 0000000..192818e
--- /dev/null
+++ b/src/HSchool.Client/src/format/gameTime.ts
@@ -0,0 +1,72 @@
+/**
+ * Formatting of the in-game calendar.
+ *
+ * The game date has no time zone: the server sends it as a UTC instant and every formatter here
+ * reads it back in UTC. Formatting in the viewer's local zone would shift the school day by
+ * whatever offset they happen to live in.
+ */
+
+const LOCALE = 'ru-RU';
+const UTC = 'UTC';
+
+const dateFormat = new Intl.DateTimeFormat(LOCALE, {
+ timeZone: UTC,
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+});
+
+const timeFormat = new Intl.DateTimeFormat(LOCALE, {
+ timeZone: UTC,
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+});
+
+const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' });
+
+const shortFormat = new Intl.DateTimeFormat(LOCALE, {
+ timeZone: UTC,
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+});
+
+/** "3 апреля 2012 г." */
+export function formatGameDate(date: Date): string {
+ return dateFormat.format(date);
+}
+
+/** "06:00" */
+export function formatGameTimeOfDay(date: Date): string {
+ return timeFormat.format(date);
+}
+
+/** "вторник" */
+export function formatGameWeekday(date: Date): string {
+ return weekdayFormat.format(date);
+}
+
+/** "03.04.2012, 06:00" — the compact form the school cards use. */
+export function formatGameDateTime(date: Date): string {
+ return shortFormat.format(date);
+}
+
+/** Splits an ISO instant into the ` ` and ` ` values it needs. */
+export function toDateAndTimeInputs(date: Date): { date: string; time: string } {
+ const iso = date.toISOString();
+ return { date: iso.slice(0, 10), time: iso.slice(11, 16) };
+}
+
+/** Rebuilds a UTC instant from those two inputs; returns null when either is missing or invalid. */
+export function fromDateAndTimeInputs(dateValue: string, timeValue: string): Date | null {
+ if (dateValue === '' || timeValue === '') {
+ return null;
+ }
+
+ const parsed = new Date(`${dateValue}T${timeValue}:00Z`);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
diff --git a/src/HSchool.Client/src/game/hud.ts b/src/HSchool.Client/src/game/hud.ts
deleted file mode 100644
index 01f5786..0000000
--- a/src/HSchool.Client/src/game/hud.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { ConnectionStatus } from '../net/connection.ts';
-
-/** Thin wrapper over the status line in `index.html`. */
-export class Hud {
- private readonly fields = new Map();
-
- constructor(root: ParentNode = document) {
- for (const element of root.querySelectorAll('[data-hud]')) {
- this.fields.set(element.dataset['hud'] ?? '', element);
- }
- }
-
- setStatus(status: ConnectionStatus): void {
- this.set('status', status);
- }
-
- setTick(tick: number): void {
- this.set('tick', `tick ${tick}`);
- }
-
- setPing(rttMs: number): void {
- this.set('ping', `${Math.round(rttMs)} ms`);
- }
-
- setEntityCount(count: number): void {
- this.set('entities', `${count} entities`);
- }
-
- private set(field: string, text: string): void {
- const element = this.fields.get(field);
- if (element !== undefined) {
- element.textContent = text;
- }
- }
-}
diff --git a/src/HSchool.Client/src/game/input.ts b/src/HSchool.Client/src/game/input.ts
deleted file mode 100644
index b359c43..0000000
--- a/src/HSchool.Client/src/game/input.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { InputButtons } from '../net/protocol.ts';
-
-/** How often the button mask is pushed to the server, independent of the render rate. */
-export const INPUT_SEND_HZ = 30;
-
-const KEY_BINDINGS: Readonly> = {
- KeyW: InputButtons.Up,
- ArrowUp: InputButtons.Up,
- KeyS: InputButtons.Down,
- ArrowDown: InputButtons.Down,
- KeyA: InputButtons.Left,
- ArrowLeft: InputButtons.Left,
- KeyD: InputButtons.Right,
- ArrowRight: InputButtons.Right,
-};
-
-/** Tracks the keyboard and pushes the current mask on a fixed cadence. */
-export class InputTracker {
- private buttons = InputButtons.None;
- private timer: ReturnType | null = null;
-
- private readonly onKeyDown = (event: KeyboardEvent): void => {
- const button = KEY_BINDINGS[event.code];
- if (button !== undefined) {
- this.buttons |= button;
- event.preventDefault();
- }
- };
-
- private readonly onKeyUp = (event: KeyboardEvent): void => {
- const button = KEY_BINDINGS[event.code];
- if (button !== undefined) {
- this.buttons &= ~button;
- event.preventDefault();
- }
- };
-
- // Alt-tabbing away must not leave a key stuck down.
- private readonly onBlur = (): void => {
- this.buttons = InputButtons.None;
- };
-
- constructor(private readonly send: (buttons: number) => void) {}
-
- get current(): number {
- return this.buttons;
- }
-
- start(target: Window = window): void {
- target.addEventListener('keydown', this.onKeyDown);
- target.addEventListener('keyup', this.onKeyUp);
- target.addEventListener('blur', this.onBlur);
-
- this.timer = setInterval(() => this.send(this.buttons), 1000 / INPUT_SEND_HZ);
- }
-
- stop(target: Window = window): void {
- target.removeEventListener('keydown', this.onKeyDown);
- target.removeEventListener('keyup', this.onKeyUp);
- target.removeEventListener('blur', this.onBlur);
-
- if (this.timer !== null) {
- clearInterval(this.timer);
- this.timer = null;
- }
- }
-}
diff --git a/src/HSchool.Client/src/game/renderer.ts b/src/HSchool.Client/src/game/renderer.ts
deleted file mode 100644
index 4bff030..0000000
--- a/src/HSchool.Client/src/game/renderer.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { Application, Container, Graphics } from 'pixi.js';
-import { EntityKind, type EntitySnapshot } from '../net/protocol.ts';
-
-const FIELD_BORDER_COLOR = 0x2a3242;
-const OWN_PLAYER_RING_COLOR = 0xffffff;
-
-/**
- * Draws the interpolated world state. One PixiJS `Graphics` per replicated entity,
- * created on first sight and destroyed when the entity disappears from a snapshot.
- */
-export class WorldRenderer {
- private readonly world = new Container();
- private readonly field = new Graphics();
- private readonly sprites = new Map();
-
- private worldWidth = 1600;
- private worldHeight = 900;
- private ownEntityId = 0;
-
- constructor(private readonly app: Application) {
- this.world.addChild(this.field);
- this.app.stage.addChild(this.world);
- this.app.renderer.on('resize', () => this.layout());
- }
-
- /** Called on every welcome frame: the server owns the field size. */
- configure(worldWidth: number, worldHeight: number, ownEntityId: number): void {
- this.worldWidth = worldWidth;
- this.worldHeight = worldHeight;
- this.ownEntityId = ownEntityId;
-
- this.field
- .clear()
- .rect(0, 0, worldWidth, worldHeight)
- .stroke({ color: FIELD_BORDER_COLOR, width: 4 });
-
- this.layout();
- }
-
- draw(entities: readonly EntitySnapshot[]): void {
- const seen = new Set();
-
- for (const entity of entities) {
- seen.add(entity.id);
-
- let sprite = this.sprites.get(entity.id);
- if (sprite === undefined) {
- sprite = this.createSprite(entity);
- this.sprites.set(entity.id, sprite);
- this.world.addChild(sprite);
- }
-
- sprite.x = entity.x;
- sprite.y = entity.y;
- }
-
- for (const [id, sprite] of this.sprites) {
- if (!seen.has(id)) {
- sprite.destroy();
- this.sprites.delete(id);
- }
- }
- }
-
- private createSprite(entity: EntitySnapshot): Graphics {
- const sprite = new Graphics();
-
- if (entity.kind === EntityKind.Obstacle) {
- sprite.roundRect(-entity.radius, -entity.radius, entity.radius * 2, entity.radius * 2, 8);
- } else {
- sprite.circle(0, 0, entity.radius);
- }
-
- sprite.fill({ color: entity.color });
-
- if (entity.id === this.ownEntityId) {
- sprite.circle(0, 0, entity.radius + 6).stroke({ color: OWN_PLAYER_RING_COLOR, width: 2, alpha: 0.9 });
- }
-
- return sprite;
- }
-
- /** Fits the whole field on screen with letterboxing, so every client sees the same area. */
- private layout(): void {
- const { width, height } = this.app.renderer.screen;
- const scale = Math.min(width / this.worldWidth, height / this.worldHeight) * 0.95;
-
- this.world.scale.set(scale);
- this.world.x = (width - this.worldWidth * scale) / 2;
- this.world.y = (height - this.worldHeight * scale) / 2;
- }
-
- /** Drops every sprite, e.g. after a reconnect assigns new replication ids. */
- reset(): void {
- for (const sprite of this.sprites.values()) {
- sprite.destroy();
- }
- this.sprites.clear();
- }
-}
diff --git a/src/HSchool.Client/src/game/snapshotBuffer.test.ts b/src/HSchool.Client/src/game/snapshotBuffer.test.ts
deleted file mode 100644
index c25b780..0000000
--- a/src/HSchool.Client/src/game/snapshotBuffer.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { SnapshotBuffer } from './snapshotBuffer.ts';
-import { EntityKind, type SnapshotMessage } from '../net/protocol.ts';
-
-function snapshot(tick: number, x: number): SnapshotMessage {
- return {
- type: 'snapshot',
- tick,
- entities: [{ id: 1, kind: EntityKind.Player, x, y: 0, radius: 10, color: 0xffffff }],
- };
-}
-
-describe('SnapshotBuffer', () => {
- it('returns nothing before the first snapshot', () => {
- expect(new SnapshotBuffer().sample(1000)).toEqual([]);
- });
-
- it('blends the two snapshots straddling the render time', () => {
- const buffer = new SnapshotBuffer(100);
- buffer.push(snapshot(1, 0), 1000);
- buffer.push(snapshot(2, 100), 1100);
-
- // Render time 1050 sits halfway between the two receive timestamps.
- const entities = buffer.sample(1150);
-
- expect(entities[0]?.x).toBeCloseTo(50);
- });
-
- it('holds at the newest snapshot when the render time has caught up', () => {
- const buffer = new SnapshotBuffer(0);
- buffer.push(snapshot(1, 0), 1000);
- buffer.push(snapshot(2, 100), 1100);
-
- expect(buffer.sample(5000)[0]?.x).toBe(100);
- expect(buffer.latestTick).toBe(2);
- });
-
- it('drops the history when the tick goes backwards after a reconnect', () => {
- const buffer = new SnapshotBuffer(100);
- buffer.push(snapshot(50, 500), 1000);
- buffer.push(snapshot(1, 0), 2000);
-
- expect(buffer.size).toBe(1);
- expect(buffer.latestTick).toBe(1);
- });
-});
diff --git a/src/HSchool.Client/src/game/snapshotBuffer.ts b/src/HSchool.Client/src/game/snapshotBuffer.ts
deleted file mode 100644
index 63faa47..0000000
--- a/src/HSchool.Client/src/game/snapshotBuffer.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { EntitySnapshot, SnapshotMessage } from '../net/protocol.ts';
-
-/** How far in the past we render, so there is always a newer snapshot to interpolate towards. */
-export const DEFAULT_INTERPOLATION_DELAY_MS = 100;
-
-const MAX_BUFFERED_SNAPSHOTS = 32;
-
-interface BufferedSnapshot {
- readonly tick: number;
- readonly receivedAt: number;
- readonly entities: readonly EntitySnapshot[];
-}
-
-/**
- * Keeps the last few snapshots and samples them slightly in the past, blending the two
- * that straddle the render time. That is what turns 20 discrete server ticks into smooth
- * motion at display refresh rate.
- */
-export class SnapshotBuffer {
- private readonly snapshots: BufferedSnapshot[] = [];
-
- constructor(private readonly delayMs: number = DEFAULT_INTERPOLATION_DELAY_MS) {}
-
- get latestTick(): number {
- return this.snapshots.at(-1)?.tick ?? 0;
- }
-
- get size(): number {
- return this.snapshots.length;
- }
-
- push(message: SnapshotMessage, receivedAt: number): void {
- // Out-of-order frames cannot happen on a WebSocket, but a reconnect resets the tick.
- const previous = this.snapshots.at(-1);
- if (previous !== undefined && message.tick < previous.tick) {
- this.snapshots.length = 0;
- }
-
- this.snapshots.push({ tick: message.tick, receivedAt, entities: message.entities });
-
- if (this.snapshots.length > MAX_BUFFERED_SNAPSHOTS) {
- this.snapshots.splice(0, this.snapshots.length - MAX_BUFFERED_SNAPSHOTS);
- }
- }
-
- /** Returns the interpolated world state for `now` (a `performance.now()` timestamp). */
- sample(now: number): readonly EntitySnapshot[] {
- if (this.snapshots.length === 0) {
- return [];
- }
-
- if (this.snapshots.length === 1) {
- return this.snapshots[0]!.entities;
- }
-
- const renderTime = now - this.delayMs;
-
- // Newest pair whose older half is at or before the render time.
- let older = this.snapshots[0]!;
- let newer = this.snapshots[1]!;
- for (let i = this.snapshots.length - 1; i > 0; i--) {
- if (this.snapshots[i - 1]!.receivedAt <= renderTime) {
- older = this.snapshots[i - 1]!;
- newer = this.snapshots[i]!;
- break;
- }
- }
-
- const span = newer.receivedAt - older.receivedAt;
- const t = span <= 0 ? 1 : clamp01((renderTime - older.receivedAt) / span);
-
- return interpolate(older.entities, newer.entities, t);
- }
-
- clear(): void {
- this.snapshots.length = 0;
- }
-}
-
-function interpolate(
- older: readonly EntitySnapshot[],
- newer: readonly EntitySnapshot[],
- t: number,
-): readonly EntitySnapshot[] {
- if (t >= 1) {
- return newer;
- }
-
- const previousById = new Map(older.map((entity) => [entity.id, entity]));
-
- // Entities missing from `newer` are gone; entities missing from `older` just spawned
- // and are drawn at their first known position.
- return newer.map((entity) => {
- const previous = previousById.get(entity.id);
- if (previous === undefined) {
- return entity;
- }
-
- return {
- ...entity,
- x: previous.x + (entity.x - previous.x) * t,
- y: previous.y + (entity.y - previous.y) * t,
- };
- });
-}
-
-function clamp01(value: number): number {
- return value < 0 ? 0 : value > 1 ? 1 : value;
-}
diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts
index 9eeecf4..dc7f96b 100644
--- a/src/HSchool.Client/src/main.ts
+++ b/src/HSchool.Client/src/main.ts
@@ -1,72 +1,87 @@
-import { Application } from 'pixi.js';
-import { GameConnection, gameSocketUrl } from './net/connection.ts';
-import { Hud } from './game/hud.ts';
-import { InputTracker } from './game/input.ts';
-import { WorldRenderer } from './game/renderer.ts';
-import { SnapshotBuffer } from './game/snapshotBuffer.ts';
-import './style.css';
-
-const BACKGROUND_COLOR = 0x10141c;
-
-async function bootstrap(): Promise {
- const app = new Application();
- await app.init({
- background: BACKGROUND_COLOR,
- resizeTo: window,
- antialias: true,
- autoDensity: true,
- resolution: window.devicePixelRatio,
- });
-
- document.getElementById('stage')?.appendChild(app.canvas);
-
- const hud = new Hud();
- const renderer = new WorldRenderer(app);
- const snapshots = new SnapshotBuffer();
-
- const connection = new GameConnection(gameSocketUrl(), playerName(), {
- onStatus: (status) => {
- hud.setStatus(status);
- if (status !== 'connected') {
- snapshots.clear();
- }
- },
- onWelcome: (welcome) => {
- // Replication ids are per-session, so anything drawn before this point is stale.
- renderer.reset();
- renderer.configure(welcome.worldWidth, welcome.worldHeight, welcome.playerEntityId);
- },
- onSnapshot: (snapshot, receivedAt) => {
- snapshots.push(snapshot, receivedAt);
- hud.setTick(snapshot.tick);
- hud.setEntityCount(snapshot.entities.length);
- },
- onLatency: (rttMs) => hud.setPing(rttMs),
- });
-
- const input = new InputTracker((buttons) => connection.sendInput(buttons));
-
- app.ticker.add(() => renderer.draw(snapshots.sample(performance.now())));
-
- connection.connect();
- input.start();
-
- window.addEventListener('beforeunload', () => {
- input.stop();
- connection.close();
- });
-}
-
-/** Keeps a name across reloads; replace with a real login when one exists. */
-function playerName(): string {
- const stored = localStorage.getItem('hschool.playerName');
- if (stored !== null) {
- return stored;
- }
-
- const generated = `player-${Math.floor(Math.random() * 10000)}`;
- localStorage.setItem('hschool.playerName', generated);
- return generated;
-}
-
-void bootstrap();
+import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
+import { GameScreen } from './ui/gameScreen.ts';
+import { MainMenu } from './ui/mainMenu.ts';
+import type { School } from './net/api.ts';
+import './style.css';
+
+const STATUS_LABELS: Record = {
+ connecting: 'подключение…',
+ connected: 'сервер на связи',
+ reconnecting: 'переподключение…',
+ closed: 'соединение закрыто',
+};
+
+/** Wires the two screens to one WebSocket connection. */
+function bootstrap(): void {
+ const app = requireElement('#app');
+ const statusLabel = document.querySelector('[data-status="connection"]');
+ const pingLabel = document.querySelector('[data-status="ping"]');
+
+ let openSchool: School | null = null;
+
+ const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
+ const game = new GameScreen({
+ onLeave: () => leaveSchool(),
+ onSetRunning: (running) => connection.setRunning(running),
+ onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
+ });
+
+ const connection = new GameConnection(gameSocketUrl(), {
+ onStatus: (status) => {
+ if (statusLabel !== null) {
+ statusLabel.textContent = STATUS_LABELS[status];
+ }
+ },
+ onClock: (clock) => {
+ if (openSchool?.id === clock.schoolId) {
+ game.update(clock);
+ }
+ },
+ onSchoolGone: (schoolId) => {
+ // Deleted from another tab while we were inside it.
+ if (openSchool?.id === schoolId) {
+ showMenu();
+ }
+ },
+ onLatency: (rttMs) => {
+ if (pingLabel !== null) {
+ pingLabel.textContent = `${Math.round(rttMs)} мс`;
+ }
+ },
+ });
+
+ function enterSchool(school: School): void {
+ openSchool = school;
+ menu.stop();
+ app.replaceChildren(game.element);
+ game.show(school);
+ connection.openSchool(school.id);
+ }
+
+ function leaveSchool(): void {
+ connection.closeSchool();
+ showMenu();
+ }
+
+ function showMenu(): void {
+ openSchool = null;
+ app.replaceChildren(menu.element);
+ menu.start();
+ }
+
+ connection.connect();
+ showMenu();
+
+ window.addEventListener('beforeunload', () => connection.close());
+}
+
+function requireElement(selector: string): HTMLElement {
+ const element = document.querySelector(selector);
+ if (element === null) {
+ throw new Error(`${selector} is missing from index.html.`);
+ }
+
+ return element;
+}
+
+bootstrap();
diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts
new file mode 100644
index 0000000..6b9450f
--- /dev/null
+++ b/src/HSchool.Client/src/net/api.ts
@@ -0,0 +1,84 @@
+/**
+ * HTTP side of the server: everything the main menu needs. The realtime clock arrives over the
+ * WebSocket instead — see `connection.ts`.
+ */
+
+export interface School {
+ readonly id: number;
+ readonly name: string;
+ /** ISO-8601 UTC instant; the in-game calendar carries no time zone. */
+ readonly gameTime: string;
+ readonly running: boolean;
+ readonly speedIndex: number;
+}
+
+export interface SchoolsResponse {
+ readonly maxSchools: number;
+ readonly defaultStartDate: string;
+ readonly gameMinutesPerRealSecond: number;
+ readonly schools: readonly School[];
+}
+
+/** A failed request, with the machine-readable `code` the server puts in its problem details. */
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export async function fetchSchools(): Promise {
+ return request('/api/schools');
+}
+
+export async function fetchRandomName(): Promise {
+ const response = await request<{ name: string }>('/api/schools/random-name');
+ return response.name;
+}
+
+export async function createSchool(name: string, startDate: Date): Promise {
+ return request('/api/schools', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name, startDate: startDate.toISOString() }),
+ });
+}
+
+export async function deleteSchool(id: number): Promise {
+ await request(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false });
+}
+
+async function request(
+ url: string,
+ init?: RequestInit,
+ options: { expectBody?: boolean } = {},
+): Promise {
+ const response = await fetch(url, init);
+
+ if (!response.ok) {
+ throw await toApiError(response);
+ }
+
+ if (options.expectBody === false) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+}
+
+async function toApiError(response: Response): Promise {
+ try {
+ // ASP.NET Core problem details; `code` is added by the server for cases the UI reacts to.
+ const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
+ return new ApiError(
+ response.status,
+ problem.code ?? 'unknown',
+ problem.detail ?? problem.title ?? response.statusText,
+ );
+ } catch {
+ return new ApiError(response.status, 'unknown', response.statusText);
+ }
+}
diff --git a/src/HSchool.Client/src/net/connection.ts b/src/HSchool.Client/src/net/connection.ts
index 100a1c9..754171d 100644
--- a/src/HSchool.Client/src/net/connection.ts
+++ b/src/HSchool.Client/src/net/connection.ts
@@ -1,158 +1,190 @@
-import {
- decodeServerMessage,
- encodeHello,
- encodeInput,
- encodePing,
- ProtocolError,
- type ServerMessage,
- type SnapshotMessage,
- type WelcomeMessage,
-} from './protocol.ts';
-
-export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
-
-export interface ConnectionHandlers {
- onStatus?(status: ConnectionStatus): void;
- onWelcome?(message: WelcomeMessage): void;
- onSnapshot?(message: SnapshotMessage, receivedAt: number): void;
- /** Round-trip time in milliseconds. */
- onLatency?(rttMs: number): void;
-}
-
-const PING_INTERVAL_MS = 2000;
-const RECONNECT_MIN_MS = 500;
-const RECONNECT_MAX_MS = 8000;
-
-/**
- * Owns the WebSocket: handshake, reconnect with backoff, ping/pong and outbound input.
- * Rendering code only sees decoded messages.
- */
-export class GameConnection {
- private socket: WebSocket | null = null;
- private pingTimer: ReturnType | null = null;
- private reconnectTimer: ReturnType | null = null;
- private reconnectDelay = RECONNECT_MIN_MS;
- private inputSequence = 0;
- private closedByUs = false;
-
- constructor(
- private readonly url: string,
- private readonly playerName: string,
- private readonly handlers: ConnectionHandlers = {},
- ) {}
-
- connect(): void {
- this.closedByUs = false;
- this.handlers.onStatus?.(this.reconnectDelay === RECONNECT_MIN_MS ? 'connecting' : 'reconnecting');
-
- const socket = new WebSocket(this.url);
- socket.binaryType = 'arraybuffer';
- this.socket = socket;
-
- socket.addEventListener('open', () => {
- this.reconnectDelay = RECONNECT_MIN_MS;
- socket.send(encodeHello(this.playerName));
- this.handlers.onStatus?.('connected');
- this.startPinging();
- });
-
- socket.addEventListener('message', (event) => this.handleMessage(event));
- socket.addEventListener('close', () => this.handleClose());
- socket.addEventListener('error', () => socket.close());
- }
-
- /** Sends the current button mask; called at a fixed rate by the input loop. */
- sendInput(buttons: number): void {
- if (this.socket?.readyState !== WebSocket.OPEN) {
- return;
- }
-
- this.inputSequence = (this.inputSequence + 1) >>> 0;
- this.socket.send(encodeInput(this.inputSequence, buttons));
- }
-
- close(): void {
- this.closedByUs = true;
- this.stopTimers();
- this.socket?.close();
- this.socket = null;
- this.handlers.onStatus?.('closed');
- }
-
- private handleMessage(event: MessageEvent): void {
- if (!(event.data instanceof ArrayBuffer)) {
- return;
- }
-
- let message: ServerMessage | null;
- try {
- message = decodeServerMessage(event.data);
- } catch (error) {
- if (error instanceof ProtocolError) {
- console.warn('Dropping malformed frame:', error.message);
- return;
- }
- throw error;
- }
-
- if (message === null) {
- return;
- }
-
- switch (message.type) {
- case 'welcome':
- this.handlers.onWelcome?.(message);
- break;
- case 'snapshot':
- this.handlers.onSnapshot?.(message, performance.now());
- break;
- case 'pong':
- this.handlers.onLatency?.(Math.max(0, Date.now() - message.clientTimeMs));
- break;
- }
- }
-
- private handleClose(): void {
- this.stopTimers();
- this.socket = null;
-
- if (this.closedByUs) {
- return;
- }
-
- this.handlers.onStatus?.('reconnecting');
- this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
- this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
- }
-
- private startPinging(): void {
- this.stopPinging();
- this.pingTimer = setInterval(() => {
- if (this.socket?.readyState === WebSocket.OPEN) {
- this.socket.send(encodePing(Date.now()));
- }
- }, PING_INTERVAL_MS);
- }
-
- private stopPinging(): void {
- if (this.pingTimer !== null) {
- clearInterval(this.pingTimer);
- this.pingTimer = null;
- }
- }
-
- private stopTimers(): void {
- this.stopPinging();
-
- if (this.reconnectTimer !== null) {
- clearTimeout(this.reconnectTimer);
- this.reconnectTimer = null;
- }
- }
-}
-
-/** Builds the game socket URL from the page origin, so the Vite proxy handles it in dev. */
-export function gameSocketUrl(path = '/ws/game'): string {
- const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
- return `${scheme}//${location.host}${path}`;
-}
+import {
+ decodeServerMessage,
+ encodeCloseSchool,
+ encodeHello,
+ encodeOpenSchool,
+ encodePing,
+ encodeSetRunning,
+ encodeSetSpeed,
+ ProtocolError,
+ type ClockMessage,
+ type ServerMessage,
+ type WelcomeMessage,
+} from './protocol.ts';
+
+export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
+
+export interface ConnectionHandlers {
+ onStatus?(status: ConnectionStatus): void;
+ onWelcome?(message: WelcomeMessage): void;
+ onClock?(message: ClockMessage): void;
+ /** The open school was deleted elsewhere; the UI has to leave it. */
+ onSchoolGone?(schoolId: number): void;
+ /** Round-trip time in milliseconds. */
+ onLatency?(rttMs: number): void;
+}
+
+const PING_INTERVAL_MS = 2000;
+const RECONNECT_MIN_MS = 500;
+const RECONNECT_MAX_MS = 8000;
+
+/**
+ * Owns the WebSocket: handshake, reconnect with backoff, ping/pong and the school commands.
+ * The UI only sees decoded messages.
+ */
+export class GameConnection {
+ private socket: WebSocket | null = null;
+ private pingTimer: ReturnType | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private reconnectDelay = RECONNECT_MIN_MS;
+ private closedByUs = false;
+
+ /** Re-sent after a reconnect so the server puts us back into the same school. */
+ private openSchoolId: number | null = null;
+
+ constructor(
+ private readonly url: string,
+ private readonly handlers: ConnectionHandlers = {},
+ ) {}
+
+ connect(): void {
+ this.closedByUs = false;
+ this.handlers.onStatus?.(this.reconnectDelay === RECONNECT_MIN_MS ? 'connecting' : 'reconnecting');
+
+ const socket = new WebSocket(this.url);
+ socket.binaryType = 'arraybuffer';
+ this.socket = socket;
+
+ socket.addEventListener('open', () => {
+ this.reconnectDelay = RECONNECT_MIN_MS;
+ socket.send(encodeHello());
+ this.handlers.onStatus?.('connected');
+ this.startPinging();
+
+ if (this.openSchoolId !== null) {
+ socket.send(encodeOpenSchool(this.openSchoolId));
+ }
+ });
+
+ socket.addEventListener('message', (event) => this.handleMessage(event));
+ socket.addEventListener('close', () => this.handleClose());
+ socket.addEventListener('error', () => socket.close());
+ }
+
+ /** Starts watching a school; its calendar starts running server-side. */
+ openSchool(schoolId: number): void {
+ this.openSchoolId = schoolId;
+ this.send(encodeOpenSchool(schoolId));
+ }
+
+ /** Back to the menu; the school stops ticking. */
+ closeSchool(): void {
+ if (this.openSchoolId === null) {
+ return;
+ }
+
+ this.openSchoolId = null;
+ this.send(encodeCloseSchool());
+ }
+
+ setRunning(running: boolean): void {
+ this.send(encodeSetRunning(running));
+ }
+
+ setSpeed(speedIndex: number): void {
+ this.send(encodeSetSpeed(speedIndex));
+ }
+
+ close(): void {
+ this.closedByUs = true;
+ this.stopTimers();
+ this.socket?.close();
+ this.socket = null;
+ this.handlers.onStatus?.('closed');
+ }
+
+ private send(frame: ArrayBuffer): void {
+ if (this.socket?.readyState === WebSocket.OPEN) {
+ this.socket.send(frame);
+ }
+ }
+
+ private handleMessage(event: MessageEvent): void {
+ if (!(event.data instanceof ArrayBuffer)) {
+ return;
+ }
+
+ let message: ServerMessage | null;
+ try {
+ message = decodeServerMessage(event.data);
+ } catch (error) {
+ if (error instanceof ProtocolError) {
+ console.warn('Dropping malformed frame:', error.message);
+ return;
+ }
+ throw error;
+ }
+
+ if (message === null) {
+ return;
+ }
+
+ switch (message.type) {
+ case 'welcome':
+ this.handlers.onWelcome?.(message);
+ break;
+ case 'clock':
+ this.handlers.onClock?.(message);
+ break;
+ case 'school-gone':
+ if (this.openSchoolId === message.schoolId) {
+ this.openSchoolId = null;
+ }
+ this.handlers.onSchoolGone?.(message.schoolId);
+ break;
+ case 'pong':
+ this.handlers.onLatency?.(Math.max(0, Date.now() - message.clientTimeMs));
+ break;
+ }
+ }
+
+ private handleClose(): void {
+ this.stopTimers();
+ this.socket = null;
+
+ if (this.closedByUs) {
+ return;
+ }
+
+ this.handlers.onStatus?.('reconnecting');
+ this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
+ }
+
+ private startPinging(): void {
+ this.stopPinging();
+ this.pingTimer = setInterval(() => this.send(encodePing(Date.now())), PING_INTERVAL_MS);
+ }
+
+ private stopPinging(): void {
+ if (this.pingTimer !== null) {
+ clearInterval(this.pingTimer);
+ this.pingTimer = null;
+ }
+ }
+
+ private stopTimers(): void {
+ this.stopPinging();
+
+ if (this.reconnectTimer !== null) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
+}
+
+/** Builds the game socket URL from the page origin, so the Vite proxy handles it in dev. */
+export function gameSocketUrl(path = '/ws/game'): string {
+ const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${scheme}//${location.host}${path}`;
+}
diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts
index 0ba0764..4bed8e1 100644
--- a/src/HSchool.Client/src/net/protocol.test.ts
+++ b/src/HSchool.Client/src/net/protocol.test.ts
@@ -1,116 +1,143 @@
-import { describe, expect, it } from 'vitest';
-import {
- decodeServerMessage,
- encodeHello,
- encodeInput,
- encodePing,
- EntityKind,
- InputButtons,
- MessageType,
- ProtocolError,
- PROTOCOL_VERSION,
-} from './protocol.ts';
-
-/**
- * These byte layouts are the contract with `ProtocolCodec.cs`. If a test here has to
- * change, the C# codec and `docs/protocol.md` change with it.
- */
-describe('client encoders', () => {
- it('writes a hello frame with version and UTF-8 name', () => {
- const view = new DataView(encodeHello('ada'));
-
- expect(view.getUint8(0)).toBe(MessageType.ClientHello);
- expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
- expect(view.getUint8(2)).toBe(3);
- expect(view.byteLength).toBe(6);
- });
-
- it('clamps oversized names to 32 bytes', () => {
- const view = new DataView(encodeHello('x'.repeat(100)));
-
- expect(view.getUint8(2)).toBe(32);
- expect(view.byteLength).toBe(35);
- });
-
- it('writes an input frame little-endian', () => {
- const buttons = InputButtons.Up | InputButtons.Right;
- const view = new DataView(encodeInput(0x01020304, buttons));
-
- expect(view.getUint8(0)).toBe(MessageType.ClientInput);
- expect(view.getUint32(1, true)).toBe(0x01020304);
- expect(view.getUint8(5)).toBe(buttons);
- });
-
- it('writes a ping frame carrying the client clock', () => {
- const view = new DataView(encodePing(1_700_000_000_123));
-
- expect(view.getUint8(0)).toBe(MessageType.ClientPing);
- expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
- });
-});
-
-describe('decodeServerMessage', () => {
- it('reads a welcome frame', () => {
- const buffer = new ArrayBuffer(15);
- const view = new DataView(buffer);
- view.setUint8(0, MessageType.ServerWelcome);
- view.setUint8(1, PROTOCOL_VERSION);
- view.setUint32(2, 42, true);
- view.setUint8(6, 20);
- view.setFloat32(7, 1600, true);
- view.setFloat32(11, 900, true);
-
- expect(decodeServerMessage(buffer)).toEqual({
- type: 'welcome',
- protocolVersion: PROTOCOL_VERSION,
- playerEntityId: 42,
- tickRate: 20,
- worldWidth: 1600,
- worldHeight: 900,
- });
- });
-
- it('reads a snapshot with every entity field', () => {
- const buffer = new ArrayBuffer(7 + 21);
- const view = new DataView(buffer);
- view.setUint8(0, MessageType.ServerSnapshot);
- view.setUint32(1, 1234, true);
- view.setUint16(5, 1, true);
- view.setUint32(7, 7, true);
- view.setUint8(11, EntityKind.Player);
- view.setFloat32(12, 100, true);
- view.setFloat32(16, 200, true);
- view.setFloat32(20, 18, true);
- view.setUint32(24, 0x4cc9f0, true);
-
- const message = decodeServerMessage(buffer);
-
- expect(message).toEqual({
- type: 'snapshot',
- tick: 1234,
- entities: [{ id: 7, kind: EntityKind.Player, x: 100, y: 200, radius: 18, color: 0x4cc9f0 }],
- });
- });
-
- it('reads a pong frame', () => {
- const buffer = new ArrayBuffer(13);
- const view = new DataView(buffer);
- view.setUint8(0, MessageType.ServerPong);
- view.setBigInt64(1, 5n, true);
- view.setUint32(9, 99, true);
-
- expect(decodeServerMessage(buffer)).toEqual({ type: 'pong', clientTimeMs: 5, serverTick: 99 });
- });
-
- it('ignores unknown message ids so new ones stay backwards compatible', () => {
- const buffer = new Uint8Array([0xf0, 0x00]).buffer;
-
- expect(decodeServerMessage(buffer)).toBeNull();
- });
-
- it('rejects a truncated frame', () => {
- const buffer = new Uint8Array([MessageType.ServerWelcome, PROTOCOL_VERSION]).buffer;
-
- expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
- });
-});
+import { describe, expect, it } from 'vitest';
+import {
+ CLOCK_SPEEDS,
+ decodeServerMessage,
+ encodeCloseSchool,
+ encodeHello,
+ encodeOpenSchool,
+ encodePing,
+ encodeSetRunning,
+ encodeSetSpeed,
+ MessageType,
+ ProtocolError,
+ PROTOCOL_VERSION,
+} from './protocol.ts';
+
+/**
+ * These byte layouts are the contract with `ProtocolCodec.cs`. If a test here has to
+ * change, the C# codec and `docs/protocol.md` change with it.
+ */
+describe('client encoders', () => {
+ it('writes a two-byte hello carrying the version', () => {
+ const view = new DataView(encodeHello());
+
+ expect(view.byteLength).toBe(2);
+ expect(view.getUint8(0)).toBe(MessageType.ClientHello);
+ expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
+ });
+
+ it('writes a ping frame carrying the client clock', () => {
+ const view = new DataView(encodePing(1_700_000_000_123));
+
+ expect(view.byteLength).toBe(9);
+ expect(view.getUint8(0)).toBe(MessageType.ClientPing);
+ expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
+ });
+
+ it('writes the school id little-endian', () => {
+ const buffer = encodeOpenSchool(0x01020304);
+ const view = new DataView(buffer);
+
+ expect(view.byteLength).toBe(5);
+ expect(view.getUint8(0)).toBe(MessageType.ClientOpenSchool);
+ expect([...new Uint8Array(buffer, 1)]).toEqual([0x04, 0x03, 0x02, 0x01]);
+ });
+
+ it('writes a single-byte close', () => {
+ const view = new DataView(encodeCloseSchool());
+
+ expect(view.byteLength).toBe(1);
+ expect(view.getUint8(0)).toBe(MessageType.ClientCloseSchool);
+ });
+
+ it('writes play/pause as its own frame', () => {
+ const view = new DataView(encodeSetRunning(true));
+
+ expect(view.byteLength).toBe(2);
+ expect(view.getUint8(0)).toBe(MessageType.ClientSetRunning);
+ expect(view.getUint8(1)).toBe(1);
+ });
+
+ it('writes the speed index as its own frame, carrying no running state', () => {
+ const view = new DataView(encodeSetSpeed(4));
+
+ expect(view.byteLength).toBe(2);
+ expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed);
+ expect(view.getUint8(1)).toBe(4);
+ });
+});
+
+describe('decodeServerMessage', () => {
+ it('reads a welcome frame', () => {
+ const buffer = new ArrayBuffer(4);
+ const view = new DataView(buffer);
+ view.setUint8(0, MessageType.ServerWelcome);
+ view.setUint8(1, PROTOCOL_VERSION);
+ view.setUint8(2, 20);
+ view.setUint8(3, 6);
+
+ expect(decodeServerMessage(buffer)).toEqual({
+ type: 'welcome',
+ protocolVersion: PROTOCOL_VERSION,
+ tickRate: 20,
+ maxSchools: 6,
+ });
+ });
+
+ it('reads a clock frame as a UTC instant', () => {
+ // 2012-04-03T06:00:00Z
+ const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
+ const buffer = new ArrayBuffer(15);
+ const view = new DataView(buffer);
+ view.setUint8(0, MessageType.ServerClock);
+ view.setInt32(1, 7, true);
+ view.setBigInt64(5, BigInt(gameTimeMs), true);
+ view.setUint8(13, 1);
+ view.setUint8(14, 2);
+
+ expect(decodeServerMessage(buffer)).toEqual({
+ type: 'clock',
+ schoolId: 7,
+ gameTime: new Date(gameTimeMs),
+ running: true,
+ speedIndex: 2,
+ });
+ });
+
+ it('reads a pong frame', () => {
+ const buffer = new ArrayBuffer(13);
+ const view = new DataView(buffer);
+ view.setUint8(0, MessageType.ServerPong);
+ view.setBigInt64(1, 5n, true);
+ view.setUint32(9, 99, true);
+
+ expect(decodeServerMessage(buffer)).toEqual({ type: 'pong', clientTimeMs: 5, serverTick: 99 });
+ });
+
+ it('reads a school-gone frame', () => {
+ const buffer = new ArrayBuffer(5);
+ const view = new DataView(buffer);
+ view.setUint8(0, MessageType.ServerSchoolGone);
+ view.setInt32(1, 3, true);
+
+ expect(decodeServerMessage(buffer)).toEqual({ type: 'school-gone', schoolId: 3 });
+ });
+
+ it('ignores unknown message ids so new ones stay backwards compatible', () => {
+ const buffer = new Uint8Array([0xf0, 0x00]).buffer;
+
+ expect(decodeServerMessage(buffer)).toBeNull();
+ });
+
+ it('rejects a truncated frame', () => {
+ const buffer = new Uint8Array([MessageType.ServerClock, 1, 2]).buffer;
+
+ expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
+ });
+});
+
+describe('clock speeds', () => {
+ it('matches the server table', () => {
+ expect([...CLOCK_SPEEDS]).toEqual([0.5, 1, 2, 3, 4]);
+ });
+});
diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts
index 9485a62..a5d0682 100644
--- a/src/HSchool.Client/src/net/protocol.ts
+++ b/src/HSchool.Client/src/net/protocol.ts
@@ -1,182 +1,189 @@
-/**
- * Browser side of the binary wire format.
- *
- * This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be
- * changed together and documented in `docs/protocol.md`. All numbers are little-endian.
- */
-
-export const PROTOCOL_VERSION = 1;
-
-export const MessageType = {
- ClientHello: 0x01,
- ClientInput: 0x02,
- ClientPing: 0x03,
- ServerWelcome: 0x81,
- ServerSnapshot: 0x82,
- ServerPong: 0x83,
-} as const;
-
-export const InputButtons = {
- None: 0,
- Up: 1 << 0,
- Down: 1 << 1,
- Left: 1 << 2,
- Right: 1 << 3,
-} as const;
-
-export const EntityKind = {
- Unknown: 0,
- Player: 1,
- Obstacle: 2,
-} as const;
-
-export type EntityKindValue = (typeof EntityKind)[keyof typeof EntityKind];
-
-export interface EntitySnapshot {
- readonly id: number;
- readonly kind: EntityKindValue;
- readonly x: number;
- readonly y: number;
- readonly radius: number;
- /** Packed 0x00RRGGBB, ready for PixiJS. */
- readonly color: number;
-}
-
-export interface WelcomeMessage {
- readonly type: 'welcome';
- readonly protocolVersion: number;
- /** Replication id of this client's own avatar. */
- readonly playerEntityId: number;
- readonly tickRate: number;
- readonly worldWidth: number;
- readonly worldHeight: number;
-}
-
-export interface SnapshotMessage {
- readonly type: 'snapshot';
- readonly tick: number;
- readonly entities: readonly EntitySnapshot[];
-}
-
-export interface PongMessage {
- readonly type: 'pong';
- readonly clientTimeMs: number;
- readonly serverTick: number;
-}
-
-export type ServerMessage = WelcomeMessage | SnapshotMessage | PongMessage;
-
-/** Thrown when a frame is truncated or carries an unexpected message id. */
-export class ProtocolError extends Error {}
-
-const encoder = new TextEncoder();
-
-export function encodeHello(playerName: string): ArrayBuffer {
- const name = encoder.encode(playerName).slice(0, 32);
- const buffer = new ArrayBuffer(3 + name.length);
- const view = new DataView(buffer);
-
- view.setUint8(0, MessageType.ClientHello);
- view.setUint8(1, PROTOCOL_VERSION);
- view.setUint8(2, name.length);
- new Uint8Array(buffer, 3).set(name);
-
- return buffer;
-}
-
-export function encodeInput(sequence: number, buttons: number): ArrayBuffer {
- const buffer = new ArrayBuffer(6);
- const view = new DataView(buffer);
-
- view.setUint8(0, MessageType.ClientInput);
- view.setUint32(1, sequence >>> 0, true);
- view.setUint8(5, buttons & 0xff);
-
- return buffer;
-}
-
-export function encodePing(clientTimeMs: number): ArrayBuffer {
- const buffer = new ArrayBuffer(9);
- const view = new DataView(buffer);
-
- view.setUint8(0, MessageType.ClientPing);
- view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true);
-
- return buffer;
-}
-
-/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
-export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
- if (data.byteLength === 0) {
- throw new ProtocolError('Empty frame.');
- }
-
- const view = new DataView(data);
- const messageType = view.getUint8(0);
-
- switch (messageType) {
- case MessageType.ServerWelcome:
- return decodeWelcome(view);
- case MessageType.ServerSnapshot:
- return decodeSnapshot(view);
- case MessageType.ServerPong:
- return decodePong(view);
- default:
- return null;
- }
-}
-
-function decodeWelcome(view: DataView): WelcomeMessage {
- ensure(view, 15);
-
- return {
- type: 'welcome',
- protocolVersion: view.getUint8(1),
- playerEntityId: view.getUint32(2, true),
- tickRate: view.getUint8(6),
- worldWidth: view.getFloat32(7, true),
- worldHeight: view.getFloat32(11, true),
- };
-}
-
-function decodeSnapshot(view: DataView): SnapshotMessage {
- ensure(view, 7);
-
- const tick = view.getUint32(1, true);
- const count = view.getUint16(5, true);
- const entitySize = 21;
- ensure(view, 7 + count * entitySize);
-
- const entities: EntitySnapshot[] = new Array(count);
- let offset = 7;
-
- for (let i = 0; i < count; i++) {
- entities[i] = {
- id: view.getUint32(offset, true),
- kind: view.getUint8(offset + 4) as EntityKindValue,
- x: view.getFloat32(offset + 5, true),
- y: view.getFloat32(offset + 9, true),
- radius: view.getFloat32(offset + 13, true),
- color: view.getUint32(offset + 17, true),
- };
- offset += entitySize;
- }
-
- return { type: 'snapshot', tick, entities };
-}
-
-function decodePong(view: DataView): PongMessage {
- ensure(view, 13);
-
- return {
- type: 'pong',
- clientTimeMs: Number(view.getBigInt64(1, true)),
- serverTick: view.getUint32(9, true),
- };
-}
-
-function ensure(view: DataView, bytes: number): void {
- if (view.byteLength < bytes) {
- throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
- }
-}
+/**
+ * Browser side of the binary wire format.
+ *
+ * This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be
+ * changed together and documented in `docs/protocol.md`. All numbers are little-endian.
+ */
+
+export const PROTOCOL_VERSION = 3;
+
+export const MessageType = {
+ ClientHello: 0x01,
+ ClientPing: 0x02,
+ ClientOpenSchool: 0x03,
+ ClientCloseSchool: 0x04,
+ ClientSetRunning: 0x05,
+ ClientSetSpeed: 0x06,
+ ServerWelcome: 0x81,
+ ServerPong: 0x82,
+ ServerClock: 0x83,
+ ServerSchoolGone: 0x84,
+} as const;
+
+/**
+ * Speed buttons, in wire order — the index travels, not the multiplier.
+ * Mirror of `ClockSpeed.Multipliers` in `src/HSchool.Simulation/ClockSpeed.cs`.
+ */
+export const CLOCK_SPEEDS = [0.5, 1, 2, 3, 4] as const;
+
+export const DEFAULT_SPEED_INDEX = 1;
+
+export interface WelcomeMessage {
+ readonly type: 'welcome';
+ readonly protocolVersion: number;
+ readonly tickRate: number;
+ readonly maxSchools: number;
+}
+
+export interface PongMessage {
+ readonly type: 'pong';
+ readonly clientTimeMs: number;
+ readonly serverTick: number;
+}
+
+export interface ClockMessage {
+ readonly type: 'clock';
+ readonly schoolId: number;
+ /** In-game date as a UTC instant; the game calendar has no time zone. */
+ readonly gameTime: Date;
+ readonly running: boolean;
+ readonly speedIndex: number;
+}
+
+export interface SchoolGoneMessage {
+ readonly type: 'school-gone';
+ readonly schoolId: number;
+}
+
+export type ServerMessage = WelcomeMessage | PongMessage | ClockMessage | SchoolGoneMessage;
+
+/** Thrown when a frame is truncated or carries an unexpected message id. */
+export class ProtocolError extends Error {}
+
+export function encodeHello(): ArrayBuffer {
+ const buffer = new ArrayBuffer(2);
+ const view = new DataView(buffer);
+
+ view.setUint8(0, MessageType.ClientHello);
+ view.setUint8(1, PROTOCOL_VERSION);
+
+ return buffer;
+}
+
+export function encodePing(clientTimeMs: number): ArrayBuffer {
+ const buffer = new ArrayBuffer(9);
+ const view = new DataView(buffer);
+
+ view.setUint8(0, MessageType.ClientPing);
+ view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true);
+
+ return buffer;
+}
+
+export function encodeOpenSchool(schoolId: number): ArrayBuffer {
+ const buffer = new ArrayBuffer(5);
+ const view = new DataView(buffer);
+
+ view.setUint8(0, MessageType.ClientOpenSchool);
+ view.setInt32(1, schoolId, true);
+
+ return buffer;
+}
+
+export function encodeCloseSchool(): ArrayBuffer {
+ const buffer = new ArrayBuffer(1);
+ new DataView(buffer).setUint8(0, MessageType.ClientCloseSchool);
+
+ return buffer;
+}
+
+/**
+ * Play/pause and speed are separate frames on purpose: a button that also resent the other field
+ * would clobber it with whatever the client last saw, which is always one tick stale.
+ */
+export function encodeSetRunning(running: boolean): ArrayBuffer {
+ const buffer = new ArrayBuffer(2);
+ const view = new DataView(buffer);
+
+ view.setUint8(0, MessageType.ClientSetRunning);
+ view.setUint8(1, running ? 1 : 0);
+
+ return buffer;
+}
+
+export function encodeSetSpeed(speedIndex: number): ArrayBuffer {
+ const buffer = new ArrayBuffer(2);
+ const view = new DataView(buffer);
+
+ view.setUint8(0, MessageType.ClientSetSpeed);
+ view.setUint8(1, speedIndex & 0xff);
+
+ return buffer;
+}
+
+/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
+export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
+ if (data.byteLength === 0) {
+ throw new ProtocolError('Empty frame.');
+ }
+
+ const view = new DataView(data);
+
+ switch (view.getUint8(0)) {
+ case MessageType.ServerWelcome:
+ return decodeWelcome(view);
+ case MessageType.ServerPong:
+ return decodePong(view);
+ case MessageType.ServerClock:
+ return decodeClock(view);
+ case MessageType.ServerSchoolGone:
+ return decodeSchoolGone(view);
+ default:
+ return null;
+ }
+}
+
+function decodeWelcome(view: DataView): WelcomeMessage {
+ ensure(view, 4);
+
+ return {
+ type: 'welcome',
+ protocolVersion: view.getUint8(1),
+ tickRate: view.getUint8(2),
+ maxSchools: view.getUint8(3),
+ };
+}
+
+function decodePong(view: DataView): PongMessage {
+ ensure(view, 13);
+
+ return {
+ type: 'pong',
+ clientTimeMs: Number(view.getBigInt64(1, true)),
+ serverTick: view.getUint32(9, true),
+ };
+}
+
+function decodeClock(view: DataView): ClockMessage {
+ ensure(view, 15);
+
+ return {
+ type: 'clock',
+ schoolId: view.getInt32(1, true),
+ gameTime: new Date(Number(view.getBigInt64(5, true))),
+ running: view.getUint8(13) !== 0,
+ speedIndex: view.getUint8(14),
+ };
+}
+
+function decodeSchoolGone(view: DataView): SchoolGoneMessage {
+ ensure(view, 5);
+
+ return { type: 'school-gone', schoolId: view.getInt32(1, true) };
+}
+
+function ensure(view: DataView, bytes: number): void {
+ if (view.byteLength < bytes) {
+ throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
+ }
+}
diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css
index 81df0fc..08fe13e 100644
--- a/src/HSchool.Client/src/style.css
+++ b/src/HSchool.Client/src/style.css
@@ -1,6 +1,16 @@
:root {
color-scheme: dark;
- font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
+
+ --surface: #10141c;
+ --surface-raised: #171d28;
+ --border: #2a3242;
+ --text: #d7e0ef;
+ --text-muted: #8b98ad;
+ --accent: #4cc9f0;
+ --danger: #f2536d;
+
+ font-family: system-ui, "Segoe UI", sans-serif;
+ color: var(--text);
}
* {
@@ -9,26 +19,272 @@
body {
margin: 0;
- overflow: hidden;
- background: #10141c;
- color: #d7e0ef;
+ min-height: 100vh;
+ background: var(--surface);
}
-#stage canvas {
- display: block;
+#app {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 32px 20px 72px;
}
-#hud {
+#status {
position: fixed;
- top: 12px;
- left: 12px;
+ right: 16px;
+ bottom: 12px;
display: flex;
+ gap: 14px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+/* Screens */
+
+.screen__header {
+ display: flex;
+ align-items: center;
gap: 16px;
+ margin-bottom: 20px;
+}
+
+.screen__title {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.screen__actions {
+ margin-left: auto;
+}
+
+.hint {
+ margin: 0 0 16px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.hint--error {
+ color: var(--danger);
+}
+
+/* School cards */
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 16px;
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface-raised);
+ cursor: pointer;
+ transition: border-color 120ms ease, transform 120ms ease;
+}
+
+.card:hover,
+.card:focus-visible {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ outline: none;
+}
+
+.card__title {
+ margin: 0;
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.card__meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.card__time {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
+}
+
+.card__badge {
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.card__actions {
+ margin-top: 8px;
+}
+
+/* Clock */
+
+.clock {
+ padding: 28px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-raised);
+ text-align: center;
+}
+
+.clock__time {
+ margin: 0;
+ font-size: 56px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+}
+
+.clock__date {
+ margin: 4px 0 0;
+ font-size: 18px;
+}
+
+.clock__weekday {
+ margin: 2px 0 0;
+ color: var(--text-muted);
+ text-transform: capitalize;
+}
+
+.clock__controls {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 20px;
+}
+
+/* Controls */
+
+.button {
padding: 8px 14px;
- border: 1px solid #2a3242;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-size: 14px;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+
+.button:hover:not([disabled]) {
+ border-color: var(--accent);
+}
+
+.button[disabled] {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.button--primary {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.button--danger {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+.button--small {
+ padding: 6px 10px;
+ font-size: 13px;
+}
+
+.button--icon {
+ min-width: 44px;
+ font-size: 16px;
+}
+
+.button--active {
+ border-color: var(--accent);
+ background: rgba(76, 201, 240, 0.16);
+ color: var(--accent);
+}
+
+.input {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
border-radius: 8px;
- background: rgba(16, 20, 28, 0.72);
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ font-size: 14px;
+}
+
+.input:focus {
+ border-color: var(--accent);
+ outline: none;
+}
+
+/* Dialogs */
+
+.dialog {
+ min-width: 340px;
+ max-width: 440px;
+ padding: 24px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface-raised);
+ color: var(--text);
+}
+
+.dialog::backdrop {
+ background: rgba(6, 9, 14, 0.7);
+}
+
+.dialog__title {
+ margin: 0 0 12px;
+ font-size: 19px;
+}
+
+.dialog__message {
+ margin: 0 0 20px;
+ color: var(--text-muted);
+}
+
+.dialog__error {
+ margin: 0;
+ color: var(--danger);
+ font-size: 14px;
+}
+
+.dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 20px;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.field__label {
font-size: 13px;
- letter-spacing: 0.02em;
- pointer-events: none;
+ color: var(--text-muted);
+}
+
+.field__row {
+ display: flex;
+ gap: 8px;
}
diff --git a/src/HSchool.Client/src/ui/confirmDialog.ts b/src/HSchool.Client/src/ui/confirmDialog.ts
new file mode 100644
index 0000000..e27144e
--- /dev/null
+++ b/src/HSchool.Client/src/ui/confirmDialog.ts
@@ -0,0 +1,41 @@
+import { el } from './dom.ts';
+import { Modal } from './modal.ts';
+
+interface ConfirmOptions {
+ readonly title: string;
+ readonly message: string;
+ readonly confirmLabel?: string;
+ readonly cancelLabel?: string;
+ /** Paints the confirm button as destructive. */
+ readonly danger?: boolean;
+}
+
+/** A modal question with two answers; resolves false when dismissed. */
+export function confirmDialog(options: ConfirmOptions): Promise {
+ const modal = new Modal(false);
+
+ const confirmButton = el('button', {
+ class: options.danger === true ? 'button button--danger' : 'button button--primary',
+ type: 'button',
+ text: options.confirmLabel ?? 'Подтвердить',
+ onClick: () => modal.close(true),
+ });
+
+ modal.element.append(
+ el('h2', { class: 'dialog__title', text: options.title }),
+ el('p', { class: 'dialog__message', text: options.message }),
+ el(
+ 'div',
+ { class: 'dialog__actions' },
+ el('button', {
+ class: 'button',
+ type: 'button',
+ text: options.cancelLabel ?? 'Отмена',
+ onClick: () => modal.close(false),
+ }),
+ confirmButton,
+ ),
+ );
+
+ return modal.open(confirmButton);
+}
diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts
new file mode 100644
index 0000000..50912ba
--- /dev/null
+++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts
@@ -0,0 +1,141 @@
+import { ApiError, type School } from '../net/api.ts';
+import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
+import { el } from './dom.ts';
+import { Modal } from './modal.ts';
+
+interface CreateSchoolOptions {
+ /** Prefilled start of the school year, straight from the server config. */
+ readonly defaultStartDate: Date;
+ readonly suggestName: () => Promise;
+ readonly create: (name: string, startDate: Date) => Promise;
+}
+
+/**
+ * The creation form: a name (typed or rolled), a start date and a create button.
+ * Resolves with the created school, or `null` when the player backs out.
+ */
+export function createSchoolDialog(options: CreateSchoolOptions): Promise {
+ const modal = new Modal(null);
+ const defaults = toDateAndTimeInputs(options.defaultStartDate);
+
+ const nameInput = el('input', { class: 'input', type: 'text' });
+ nameInput.maxLength = 40;
+ nameInput.placeholder = 'Название школы';
+ nameInput.required = true;
+
+ const dateInput = el('input', { class: 'input', type: 'date' });
+ dateInput.value = defaults.date;
+ dateInput.required = true;
+
+ const timeInput = el('input', { class: 'input', type: 'time' });
+ timeInput.value = defaults.time;
+ timeInput.required = true;
+
+ const error = el('p', { class: 'dialog__error' });
+ error.hidden = true;
+
+ const randomButton = el('button', {
+ class: 'button',
+ type: 'button',
+ text: 'Случайное',
+ title: 'Придумать название',
+ });
+
+ const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: 'Создать' });
+
+ const form = el(
+ 'form',
+ { class: 'form' },
+ el(
+ 'label',
+ { class: 'field' },
+ el('span', { class: 'field__label', text: 'Название' }),
+ el('div', { class: 'field__row' }, nameInput, randomButton),
+ ),
+ el(
+ 'div',
+ { class: 'field' },
+ el('span', { class: 'field__label', text: 'Начало игры' }),
+ el('div', { class: 'field__row' }, dateInput, timeInput),
+ ),
+ error,
+ el(
+ 'div',
+ { class: 'dialog__actions' },
+ el('button', { class: 'button', type: 'button', text: 'Отмена', onClick: () => modal.close(null) }),
+ submitButton,
+ ),
+ );
+
+ let busy = false;
+
+ const setBusy = (value: boolean): void => {
+ busy = value;
+ submitButton.toggleAttribute('disabled', value);
+ randomButton.toggleAttribute('disabled', value);
+ };
+
+ const showError = (message: string): void => {
+ error.textContent = message;
+ error.hidden = false;
+ };
+
+ randomButton.addEventListener('click', () => {
+ if (busy) {
+ return;
+ }
+
+ setBusy(true);
+ options
+ .suggestName()
+ .then((name) => {
+ nameInput.value = name;
+ error.hidden = true;
+ })
+ .catch(() => showError('Не удалось получить название с сервера.'))
+ .finally(() => setBusy(false));
+ });
+
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ if (busy) {
+ return;
+ }
+
+ const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
+ if (startDate === null) {
+ showError('Укажите дату и время начала.');
+ return;
+ }
+
+ setBusy(true);
+ options
+ .create(nameInput.value.trim(), startDate)
+ .then((school) => modal.close(school))
+ .catch((reason: unknown) => {
+ showError(describe(reason));
+ setBusy(false);
+ });
+ });
+
+ modal.element.append(el('h2', { class: 'dialog__title', text: 'Новая школа' }), form);
+
+ return modal.open(nameInput);
+}
+
+function describe(reason: unknown): string {
+ if (!(reason instanceof ApiError)) {
+ return 'Сервер недоступен. Попробуйте ещё раз.';
+ }
+
+ switch (reason.code) {
+ case 'school-limit-reached':
+ return 'Достигнут лимит школ — удалите одну, чтобы создать новую.';
+ case 'invalid-name':
+ return 'Название должно быть от 1 до 40 символов.';
+ case 'invalid-start-date':
+ return 'Дата начала вне допустимого диапазона.';
+ default:
+ return reason.message;
+ }
+}
diff --git a/src/HSchool.Client/src/ui/dom.ts b/src/HSchool.Client/src/ui/dom.ts
new file mode 100644
index 0000000..9d9df4c
--- /dev/null
+++ b/src/HSchool.Client/src/ui/dom.ts
@@ -0,0 +1,49 @@
+/** Tiny helpers so the screens can build DOM without a framework or string templates. */
+
+type Child = Node | string | null | undefined | false;
+
+interface ElementOptions {
+ class?: string;
+ text?: string;
+ title?: string;
+ type?: string;
+ disabled?: boolean;
+ dataset?: Record;
+ onClick?: (event: Event) => void;
+}
+
+export function el(
+ tag: K,
+ options: ElementOptions = {},
+ ...children: Child[]
+): HTMLElementTagNameMap[K] {
+ const element = document.createElement(tag);
+
+ if (options.class !== undefined) element.className = options.class;
+ if (options.text !== undefined) element.textContent = options.text;
+ if (options.title !== undefined) element.title = options.title;
+ if (options.type !== undefined) element.setAttribute('type', options.type);
+ if (options.disabled !== undefined) element.toggleAttribute('disabled', options.disabled);
+ if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
+
+ for (const [key, value] of Object.entries(options.dataset ?? {})) {
+ element.dataset[key] = value;
+ }
+
+ append(element, children);
+ return element;
+}
+
+export function append(parent: Node, children: Child[]): void {
+ for (const child of children) {
+ if (child === null || child === undefined || child === false) {
+ continue;
+ }
+
+ parent.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
+ }
+}
+
+export function clear(element: Element): void {
+ element.replaceChildren();
+}
diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts
new file mode 100644
index 0000000..1958390
--- /dev/null
+++ b/src/HSchool.Client/src/ui/gameScreen.ts
@@ -0,0 +1,88 @@
+import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
+import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
+import type { School } from '../net/api.ts';
+import { el } from './dom.ts';
+
+interface GameScreenOptions {
+ readonly onLeave: () => void;
+ readonly onSetRunning: (running: boolean) => void;
+ readonly onSetSpeed: (speedIndex: number) => void;
+}
+
+const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
+
+/**
+ * The inside of a school: for now the calendar and the controls that drive it. The server owns
+ * the clock, so every button only sends an intent and the display follows the next clock frame.
+ */
+export class GameScreen {
+ private readonly root = el('section', { class: 'screen game' });
+ private readonly schoolName = el('h1', { class: 'screen__title' });
+ private readonly time = el('p', { class: 'clock__time', text: '--:--' });
+ private readonly date = el('p', { class: 'clock__date' });
+ private readonly weekday = el('p', { class: 'clock__weekday' });
+ private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
+ private readonly speedButtons: HTMLButtonElement[];
+
+ private running = false;
+
+ constructor(options: GameScreenOptions) {
+ this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
+ el('button', {
+ class: 'button button--small',
+ type: 'button',
+ text: SPEED_LABELS[index] ?? `×${CLOCK_SPEEDS[index]}`,
+ onClick: () => options.onSetSpeed(index),
+ }),
+ );
+
+ this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
+
+ this.root.append(
+ el(
+ 'header',
+ { class: 'screen__header' },
+ el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }),
+ this.schoolName,
+ ),
+ el(
+ 'div',
+ { class: 'clock' },
+ this.time,
+ this.date,
+ this.weekday,
+ el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
+ ),
+ el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }),
+ );
+ }
+
+ get element(): HTMLElement {
+ return this.root;
+ }
+
+ /** Called when the screen opens, before the first clock frame arrives. */
+ show(school: School): void {
+ this.schoolName.textContent = school.name;
+ this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
+ }
+
+ update(clock: ClockMessage): void {
+ this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
+ }
+
+ private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
+ this.running = running;
+
+ this.time.textContent = formatGameTimeOfDay(gameTime);
+ this.date.textContent = formatGameDate(gameTime);
+ this.weekday.textContent = formatGameWeekday(gameTime);
+
+ this.playPauseButton.textContent = running ? '⏸' : '▶';
+ this.playPauseButton.title = running ? 'Пауза' : 'Продолжить';
+
+ this.speedButtons.forEach((button, index) => {
+ button.classList.toggle('button--active', index === speedIndex);
+ });
+ }
+}
diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts
new file mode 100644
index 0000000..36acd10
--- /dev/null
+++ b/src/HSchool.Client/src/ui/mainMenu.ts
@@ -0,0 +1,192 @@
+import {
+ createSchool,
+ deleteSchool,
+ fetchRandomName,
+ fetchSchools,
+ type School,
+ type SchoolsResponse,
+} from '../net/api.ts';
+import { el } from './dom.ts';
+import { confirmDialog } from './confirmDialog.ts';
+import { createSchoolDialog } from './createSchoolDialog.ts';
+import { SchoolCard } from './schoolCard.ts';
+
+interface MainMenuOptions {
+ readonly onOpenSchool: (school: School) => void;
+}
+
+/**
+ * The list of saves. Schools keep living while you are in the menu, so the list is re-read on a
+ * timer — the server is the only thing that knows what time it is in each of them.
+ *
+ * One second is well below the resolution the cards show: at ×1 a game minute passes every
+ * 12 real seconds, at ×4 every 3.
+ */
+const REFRESH_INTERVAL_MS = 1000;
+
+export class MainMenu {
+ private readonly root = el('section', { class: 'screen menu' });
+ private readonly grid = el('div', { class: 'card-grid' });
+ private readonly emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' });
+ private readonly createButton = el('button', {
+ class: 'button button--primary',
+ type: 'button',
+ text: 'Создать школу',
+ });
+
+ private readonly limitHint = el('p', { class: 'hint' });
+ private readonly status = el('p', { class: 'hint hint--error' });
+ private readonly cards = new Map();
+
+ private state: SchoolsResponse | null = null;
+ private refreshTimer: ReturnType | null = null;
+ private busy = false;
+
+ constructor(private readonly options: MainMenuOptions) {
+ this.createButton.addEventListener('click', () => void this.openCreateDialog());
+ this.status.hidden = true;
+
+ this.root.append(
+ el(
+ 'header',
+ { class: 'screen__header' },
+ el('h1', { class: 'screen__title', text: 'Школы' }),
+ el('div', { class: 'screen__actions' }, this.createButton),
+ ),
+ this.limitHint,
+ this.status,
+ this.emptyHint,
+ this.grid,
+ );
+ }
+
+ get element(): HTMLElement {
+ return this.root;
+ }
+
+ /** Shows the menu: loads the list once, then keeps it fresh. */
+ start(): void {
+ void this.refresh();
+
+ this.stop();
+
+ // No visibility check here: browsers already throttle timers in background tabs, and a
+ // hidden-tab guard silently freezes the list in embedded views that report themselves hidden.
+ this.refreshTimer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
+ }
+
+ /** Called when another screen takes over. */
+ stop(): void {
+ if (this.refreshTimer !== null) {
+ clearInterval(this.refreshTimer);
+ this.refreshTimer = null;
+ }
+ }
+
+ async refresh(): Promise {
+ try {
+ this.state = await fetchSchools();
+ this.status.hidden = true;
+ } catch {
+ this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.';
+ this.status.hidden = false;
+ return;
+ }
+
+ this.render();
+ }
+
+ private render(): void {
+ const state = this.state;
+ if (state === null) {
+ return;
+ }
+
+ const atLimit = state.schools.length >= state.maxSchools;
+ this.createButton.toggleAttribute('disabled', atLimit || this.busy);
+ this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : '';
+
+ this.limitHint.textContent = atLimit
+ ? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.`
+ : `Школ: ${state.schools.length} из ${state.maxSchools}.`;
+
+ this.emptyHint.hidden = state.schools.length > 0;
+
+ // Patch the cards that are already on screen; only added and removed schools touch the DOM.
+ const seen = new Set();
+ for (const school of state.schools) {
+ seen.add(school.id);
+
+ const card = this.cards.get(school.id);
+ if (card === undefined) {
+ const created = new SchoolCard(school, {
+ onOpen: (opened) => this.options.onOpenSchool(opened),
+ onDelete: (target) => void this.confirmDelete(target),
+ });
+
+ this.cards.set(school.id, created);
+ this.grid.appendChild(created.element);
+ } else {
+ card.update(school);
+ }
+ }
+
+ for (const [id, card] of this.cards) {
+ if (!seen.has(id)) {
+ card.element.remove();
+ this.cards.delete(id);
+ }
+ }
+ }
+
+ private async confirmDelete(school: School): Promise {
+ const confirmed = await confirmDialog({
+ title: 'Удалить школу?',
+ message: `«${school.name}» будет удалена без возможности восстановления.`,
+ confirmLabel: 'Удалить',
+ danger: true,
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await deleteSchool(school.id);
+ } catch {
+ this.status.textContent = `Не удалось удалить «${school.name}».`;
+ this.status.hidden = false;
+ }
+
+ await this.refresh();
+ }
+
+ private async openCreateDialog(): Promise {
+ const state = this.state;
+ if (state === null || this.busy) {
+ return;
+ }
+
+ this.busy = true;
+ try {
+ await createSchoolDialog({
+ defaultStartDate: new Date(state.defaultStartDate),
+ suggestName: fetchRandomName,
+ create: createSchool,
+ });
+ } finally {
+ this.busy = false;
+ }
+
+ await this.refresh();
+ }
+}
+
+function plural(count: number): string {
+ const remainderTen = count % 10;
+ const remainderHundred = count % 100;
+
+ if (remainderTen === 1 && remainderHundred !== 11) return 'школа';
+ if (remainderTen >= 2 && remainderTen <= 4 && (remainderHundred < 12 || remainderHundred > 14)) return 'школы';
+ return 'школ';
+}
diff --git a/src/HSchool.Client/src/ui/modal.ts b/src/HSchool.Client/src/ui/modal.ts
new file mode 100644
index 0000000..5fd26f0
--- /dev/null
+++ b/src/HSchool.Client/src/ui/modal.ts
@@ -0,0 +1,46 @@
+import { el } from './dom.ts';
+
+/**
+ * A `` that resolves a promise when it is dismissed.
+ *
+ * Every exit resolves *explicitly* instead of listening for the `close` event: that event is not
+ * delivered by every engine (Chromium 148 fires only `toggle`), and a dialog whose promise never
+ * settles silently freezes the screen that awaited it. `cancel` is still wired up because that is
+ * how Escape reports itself.
+ */
+export class Modal {
+ readonly element = el('dialog', { class: 'dialog' });
+
+ private readonly result: Promise;
+ private settle: (value: T) => void = () => {};
+ private settled = false;
+
+ constructor(private readonly dismissedValue: T) {
+ this.result = new Promise((resolve) => {
+ this.settle = resolve;
+ });
+
+ this.element.addEventListener('cancel', () => this.close(this.dismissedValue));
+ }
+
+ /** Shows the modal and returns the promise the caller awaits. */
+ open(focus?: HTMLElement): Promise {
+ document.body.appendChild(this.element);
+ this.element.showModal();
+ focus?.focus();
+
+ return this.result;
+ }
+
+ /** Closes the modal and resolves the promise. Safe to call more than once. */
+ close(value: T): void {
+ if (this.settled) {
+ return;
+ }
+
+ this.settled = true;
+ this.element.close();
+ this.element.remove();
+ this.settle(value);
+ }
+}
diff --git a/src/HSchool.Client/src/ui/schoolCard.ts b/src/HSchool.Client/src/ui/schoolCard.ts
new file mode 100644
index 0000000..d57c153
--- /dev/null
+++ b/src/HSchool.Client/src/ui/schoolCard.ts
@@ -0,0 +1,65 @@
+import type { School } from '../net/api.ts';
+import { formatGameDateTime } from '../format/gameTime.ts';
+import { el } from './dom.ts';
+
+interface SchoolCardOptions {
+ readonly onOpen: (school: School) => void;
+ readonly onDelete: (school: School) => void;
+}
+
+/**
+ * One save in the main menu. The card is patched in place rather than rebuilt, because the menu
+ * refreshes every second and a rebuilt card would drop focus and hover mid-click.
+ */
+export class SchoolCard {
+ readonly element = el('article', { class: 'card' });
+
+ private readonly title = el('h2', { class: 'card__title' });
+ private readonly time = el('p', { class: 'card__time' });
+ private readonly pausedBadge = el('span', { class: 'card__badge', text: '⏸ на паузе' });
+
+ private school: School;
+
+ constructor(school: School, options: SchoolCardOptions) {
+ this.school = school;
+
+ this.element.dataset['schoolId'] = String(school.id);
+ this.element.tabIndex = 0;
+ this.element.append(
+ this.title,
+ el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
+ el(
+ 'div',
+ { class: 'card__actions' },
+ el('button', {
+ class: 'button button--danger button--small',
+ type: 'button',
+ text: 'Удалить',
+ onClick: (event) => {
+ // The whole card is clickable, so the delete button must not open the school too.
+ event.stopPropagation();
+ options.onDelete(this.school);
+ },
+ }),
+ ),
+ );
+
+ this.element.addEventListener('click', () => options.onOpen(this.school));
+ this.element.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ options.onOpen(this.school);
+ }
+ });
+
+ this.update(school);
+ }
+
+ update(school: School): void {
+ this.school = school;
+
+ this.title.textContent = school.name;
+ this.time.textContent = formatGameDateTime(new Date(school.gameTime));
+ this.pausedBadge.hidden = school.running;
+ }
+}
diff --git a/src/HSchool.Protocol/EntityKind.cs b/src/HSchool.Protocol/EntityKind.cs
deleted file mode 100644
index 7fa7ebe..0000000
--- a/src/HSchool.Protocol/EntityKind.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace HSchool.Protocol;
-
-/// Tells the renderer which visual to use for a snapshot entity.
-public enum EntityKind : byte
-{
- Unknown = 0,
- Player = 1,
- Obstacle = 2,
-}
diff --git a/src/HSchool.Protocol/InputButtons.cs b/src/HSchool.Protocol/InputButtons.cs
deleted file mode 100644
index 52e1c0a..0000000
--- a/src/HSchool.Protocol/InputButtons.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace HSchool.Protocol;
-
-/// Bitmask of movement intents sent by the client each input frame.
-[Flags]
-public enum InputButtons : byte
-{
- None = 0,
- Up = 1 << 0,
- Down = 1 << 1,
- Left = 1 << 2,
- Right = 1 << 3,
-}
diff --git a/src/HSchool.Protocol/MessageType.cs b/src/HSchool.Protocol/MessageType.cs
index a78275d..3091444 100644
--- a/src/HSchool.Protocol/MessageType.cs
+++ b/src/HSchool.Protocol/MessageType.cs
@@ -1,18 +1,22 @@
-namespace HSchool.Protocol;
-
-///
-/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
-/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
-///
-public enum MessageType : byte
-{
- None = 0x00,
-
- ClientHello = 0x01,
- ClientInput = 0x02,
- ClientPing = 0x03,
-
- ServerWelcome = 0x81,
- ServerSnapshot = 0x82,
- ServerPong = 0x83,
-}
+namespace HSchool.Protocol;
+
+///
+/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
+/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
+///
+public enum MessageType : byte
+{
+ None = 0x00,
+
+ ClientHello = 0x01,
+ ClientPing = 0x02,
+ ClientOpenSchool = 0x03,
+ ClientCloseSchool = 0x04,
+ ClientSetRunning = 0x05,
+ ClientSetSpeed = 0x06,
+
+ ServerWelcome = 0x81,
+ ServerPong = 0x82,
+ ServerClock = 0x83,
+ ServerSchoolGone = 0x84,
+}
diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs
index 442bba0..7d1f823 100644
--- a/src/HSchool.Protocol/Messages.cs
+++ b/src/HSchool.Protocol/Messages.cs
@@ -1,37 +1,39 @@
namespace HSchool.Protocol;
-/// First frame from the client: protocol version handshake plus display name.
-public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
-
-///
-/// Movement intent for one client frame. is echoed back
-/// in future snapshots once client-side prediction lands.
-///
-public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
+/// First frame from the client; carries nothing but the version handshake.
+public readonly record struct ClientHelloMessage(byte ProtocolVersion);
/// Round-trip probe; the server mirrors back untouched.
public readonly record struct ClientPingMessage(long ClientTimeMs);
-///
-/// Sent once per connection, before the first snapshot.
-/// is the replication id of this client's own avatar,
-/// so the renderer can tell it apart from everyone else.
-///
-public readonly record struct ServerWelcomeMessage(
- byte ProtocolVersion,
- uint PlayerEntityId,
- byte TickRate,
- float WorldWidth,
- float WorldHeight);
+/// Asks for clock updates of one school. Starts its calendar running.
+public readonly record struct ClientOpenSchoolMessage(int SchoolId);
-/// One entity inside a snapshot. Kept flat and blittable on purpose.
-public readonly record struct EntitySnapshot(
- uint Id,
- EntityKind Kind,
- float X,
- float Y,
- float Radius,
- uint Color);
+///
+/// Play or pause the open school. Running and speed are separate messages on purpose: a button
+/// that also resent the other field would clobber it with whatever the client last saw.
+///
+public readonly record struct ClientSetRunningMessage(bool Running);
+
+/// Change the speed of the open school without touching whether it runs.
+public readonly record struct ClientSetSpeedMessage(byte SpeedIndex);
+
+/// Sent once per connection, before anything else.
+public readonly record struct ServerWelcomeMessage(byte ProtocolVersion, byte TickRate, byte MaxSchools);
/// Answer to , carrying the current server tick.
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
+
+///
+/// State of the open school's calendar, sent every tick.
+/// is the in-game date as milliseconds since the Unix epoch,
+/// interpreted as UTC — the game calendar has no time zone.
+///
+public readonly record struct ServerClockMessage(
+ int SchoolId,
+ long GameTimeUnixMs,
+ bool Running,
+ byte SpeedIndex);
+
+/// The open school no longer exists (deleted from another tab); the client returns to the menu.
+public readonly record struct ServerSchoolGoneMessage(int SchoolId);
diff --git a/src/HSchool.Protocol/PacketReader.cs b/src/HSchool.Protocol/PacketReader.cs
index 6b5ede4..2b81c17 100644
--- a/src/HSchool.Protocol/PacketReader.cs
+++ b/src/HSchool.Protocol/PacketReader.cs
@@ -1,75 +1,57 @@
-using System.Buffers.Binary;
-using System.Text;
-
-namespace HSchool.Protocol;
-
-/// Little-endian cursor over a received frame. Mirror of .
-public ref struct PacketReader(ReadOnlySpan buffer)
-{
- private readonly ReadOnlySpan _buffer = buffer;
- private int _position = 0;
-
- public readonly int Position => _position;
-
- public readonly int Remaining => _buffer.Length - _position;
-
- public byte ReadByte()
- {
- EnsureAvailable(sizeof(byte));
- var value = _buffer[_position];
- _position += sizeof(byte);
- return value;
- }
-
- public MessageType ReadMessageType() => (MessageType)ReadByte();
-
- public ushort ReadUInt16()
- {
- EnsureAvailable(sizeof(ushort));
- var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
- _position += sizeof(ushort);
- return value;
- }
-
- public uint ReadUInt32()
- {
- EnsureAvailable(sizeof(uint));
- var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
- _position += sizeof(uint);
- return value;
- }
-
- public long ReadInt64()
- {
- EnsureAvailable(sizeof(long));
- var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
- _position += sizeof(long);
- return value;
- }
-
- public float ReadSingle()
- {
- EnsureAvailable(sizeof(float));
- var value = BinaryPrimitives.ReadSingleLittleEndian(_buffer[_position..]);
- _position += sizeof(float);
- return value;
- }
-
- public string ReadShortString()
- {
- var byteCount = ReadByte();
- EnsureAvailable(byteCount);
- var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
- _position += byteCount;
- return value;
- }
-
- private readonly void EnsureAvailable(int bytes)
- {
- if (_position + bytes > _buffer.Length)
- {
- throw new ProtocolException(
- $"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
- }
- }
-}
+using System.Buffers.Binary;
+
+namespace HSchool.Protocol;
+
+/// Little-endian cursor over a received frame. Mirror of .
+public ref struct PacketReader(ReadOnlySpan buffer)
+{
+ private readonly ReadOnlySpan _buffer = buffer;
+ private int _position = 0;
+
+ public readonly int Position => _position;
+
+ public readonly int Remaining => _buffer.Length - _position;
+
+ public byte ReadByte()
+ {
+ EnsureAvailable(sizeof(byte));
+ var value = _buffer[_position];
+ _position += sizeof(byte);
+ return value;
+ }
+
+ public MessageType ReadMessageType() => (MessageType)ReadByte();
+
+ public uint ReadUInt32()
+ {
+ EnsureAvailable(sizeof(uint));
+ var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
+ _position += sizeof(uint);
+ return value;
+ }
+
+ public int ReadInt32()
+ {
+ EnsureAvailable(sizeof(int));
+ var value = BinaryPrimitives.ReadInt32LittleEndian(_buffer[_position..]);
+ _position += sizeof(int);
+ return value;
+ }
+
+ public long ReadInt64()
+ {
+ EnsureAvailable(sizeof(long));
+ var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
+ _position += sizeof(long);
+ return value;
+ }
+
+ private readonly void EnsureAvailable(int bytes)
+ {
+ if (_position + bytes > _buffer.Length)
+ {
+ throw new ProtocolException(
+ $"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
+ }
+ }
+}
diff --git a/src/HSchool.Protocol/PacketWriter.cs b/src/HSchool.Protocol/PacketWriter.cs
index 930dc29..1550c72 100644
--- a/src/HSchool.Protocol/PacketWriter.cs
+++ b/src/HSchool.Protocol/PacketWriter.cs
@@ -1,80 +1,54 @@
-using System.Buffers.Binary;
-using System.Text;
-
-namespace HSchool.Protocol;
-
-///
-/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
-/// browser's DataView calls in src/HSchool.Client/src/net/protocol.ts .
-///
-public ref struct PacketWriter(Span buffer)
-{
- private readonly Span _buffer = buffer;
- private int _position = 0;
-
- public readonly int Position => _position;
-
- public readonly ReadOnlySpan Written => _buffer[.._position];
-
- public void WriteByte(byte value)
- {
- EnsureRoom(sizeof(byte));
- _buffer[_position] = value;
- _position += sizeof(byte);
- }
-
- public void WriteMessageType(MessageType value) => WriteByte((byte)value);
-
- public void WriteUInt16(ushort value)
- {
- EnsureRoom(sizeof(ushort));
- BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
- _position += sizeof(ushort);
- }
-
- public void WriteUInt32(uint value)
- {
- EnsureRoom(sizeof(uint));
- BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
- _position += sizeof(uint);
- }
-
- public void WriteInt64(long value)
- {
- EnsureRoom(sizeof(long));
- BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
- _position += sizeof(long);
- }
-
- public void WriteSingle(float value)
- {
- EnsureRoom(sizeof(float));
- BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
- _position += sizeof(float);
- }
-
- /// Writes a UTF-8 string prefixed with a single length byte.
- public void WriteShortString(string value)
- {
- var byteCount = Encoding.UTF8.GetByteCount(value);
- if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
- {
- throw new ProtocolException(
- $"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
- }
-
- WriteByte((byte)byteCount);
- EnsureRoom(byteCount);
- Encoding.UTF8.GetBytes(value, _buffer[_position..]);
- _position += byteCount;
- }
-
- private readonly void EnsureRoom(int bytes)
- {
- if (_position + bytes > _buffer.Length)
- {
- throw new ProtocolException(
- $"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
- }
- }
-}
+using System.Buffers.Binary;
+
+namespace HSchool.Protocol;
+
+///
+/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
+/// browser's DataView calls in src/HSchool.Client/src/net/protocol.ts .
+///
+public ref struct PacketWriter(Span buffer)
+{
+ private readonly Span _buffer = buffer;
+ private int _position = 0;
+
+ public readonly int Position => _position;
+
+ public void WriteByte(byte value)
+ {
+ EnsureRoom(sizeof(byte));
+ _buffer[_position] = value;
+ _position += sizeof(byte);
+ }
+
+ public void WriteMessageType(MessageType value) => WriteByte((byte)value);
+
+ public void WriteUInt32(uint value)
+ {
+ EnsureRoom(sizeof(uint));
+ BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
+ _position += sizeof(uint);
+ }
+
+ public void WriteInt32(int value)
+ {
+ EnsureRoom(sizeof(int));
+ BinaryPrimitives.WriteInt32LittleEndian(_buffer[_position..], value);
+ _position += sizeof(int);
+ }
+
+ public void WriteInt64(long value)
+ {
+ EnsureRoom(sizeof(long));
+ BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
+ _position += sizeof(long);
+ }
+
+ private readonly void EnsureRoom(int bytes)
+ {
+ if (_position + bytes > _buffer.Length)
+ {
+ throw new ProtocolException(
+ $"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
+ }
+ }
+}
diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs
index 6c52746..897777f 100644
--- a/src/HSchool.Protocol/ProtocolCodec.cs
+++ b/src/HSchool.Protocol/ProtocolCodec.cs
@@ -7,21 +7,14 @@ namespace HSchool.Protocol;
///
public static class ProtocolCodec
{
+ /// Largest frame this codec produces; handlers can size their buffers from it.
+ public const int MaxFrameSize = 16;
+
public static int WriteHello(Span destination, in ClientHelloMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion);
- writer.WriteShortString(message.PlayerName);
- return writer.Position;
- }
-
- public static int WriteInput(Span destination, in ClientInputMessage message)
- {
- var writer = new PacketWriter(destination);
- writer.WriteMessageType(MessageType.ClientInput);
- writer.WriteUInt32(message.Sequence);
- writer.WriteByte((byte)message.Buttons);
return writer.Position;
}
@@ -33,15 +26,44 @@ public static class ProtocolCodec
return writer.Position;
}
+ public static int WriteOpenSchool(Span destination, in ClientOpenSchoolMessage message)
+ {
+ var writer = new PacketWriter(destination);
+ writer.WriteMessageType(MessageType.ClientOpenSchool);
+ writer.WriteInt32(message.SchoolId);
+ return writer.Position;
+ }
+
+ public static int WriteCloseSchool(Span destination)
+ {
+ var writer = new PacketWriter(destination);
+ writer.WriteMessageType(MessageType.ClientCloseSchool);
+ return writer.Position;
+ }
+
+ public static int WriteSetRunning(Span destination, in ClientSetRunningMessage message)
+ {
+ var writer = new PacketWriter(destination);
+ writer.WriteMessageType(MessageType.ClientSetRunning);
+ writer.WriteByte(message.Running ? (byte)1 : (byte)0);
+ return writer.Position;
+ }
+
+ public static int WriteSetSpeed(Span destination, in ClientSetSpeedMessage message)
+ {
+ var writer = new PacketWriter(destination);
+ writer.WriteMessageType(MessageType.ClientSetSpeed);
+ writer.WriteByte(message.SpeedIndex);
+ return writer.Position;
+ }
+
public static int WriteWelcome(Span destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerWelcome);
writer.WriteByte(message.ProtocolVersion);
- writer.WriteUInt32(message.PlayerEntityId);
writer.WriteByte(message.TickRate);
- writer.WriteSingle(message.WorldWidth);
- writer.WriteSingle(message.WorldHeight);
+ writer.WriteByte(message.MaxSchools);
return writer.Position;
}
@@ -54,35 +76,24 @@ public static class ProtocolCodec
return writer.Position;
}
- /// Writes a full-state snapshot; entities missing from it are despawned by the client.
- public static int WriteSnapshot(Span destination, uint tick, ReadOnlySpan entities)
+ public static int WriteClock(Span destination, in ServerClockMessage message)
{
- if (entities.Length > ushort.MaxValue)
- {
- throw new ProtocolException($"Snapshot holds {entities.Length} entities, limit is {ushort.MaxValue}.");
- }
-
var writer = new PacketWriter(destination);
- writer.WriteMessageType(MessageType.ServerSnapshot);
- writer.WriteUInt32(tick);
- writer.WriteUInt16((ushort)entities.Length);
-
- foreach (var entity in entities)
- {
- writer.WriteUInt32(entity.Id);
- writer.WriteByte((byte)entity.Kind);
- writer.WriteSingle(entity.X);
- writer.WriteSingle(entity.Y);
- writer.WriteSingle(entity.Radius);
- writer.WriteUInt32(entity.Color);
- }
-
+ writer.WriteMessageType(MessageType.ServerClock);
+ writer.WriteInt32(message.SchoolId);
+ writer.WriteInt64(message.GameTimeUnixMs);
+ writer.WriteByte(message.Running ? (byte)1 : (byte)0);
+ writer.WriteByte(message.SpeedIndex);
return writer.Position;
}
- /// Exact byte size of a snapshot frame for entities.
- public static int SnapshotSize(int entityCount) =>
- ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
+ public static int WriteSchoolGone(Span destination, in ServerSchoolGoneMessage message)
+ {
+ var writer = new PacketWriter(destination);
+ writer.WriteMessageType(MessageType.ServerSchoolGone);
+ writer.WriteInt32(message.SchoolId);
+ return writer.Position;
+ }
public static MessageType PeekMessageType(ReadOnlySpan source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -91,18 +102,7 @@ public static class ProtocolCodec
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello);
- var version = reader.ReadByte();
- var name = reader.ReadShortString();
- return new ClientHelloMessage(version, name);
- }
-
- public static ClientInputMessage ReadInput(ReadOnlySpan source)
- {
- var reader = new PacketReader(source);
- Expect(ref reader, MessageType.ClientInput);
- var sequence = reader.ReadUInt32();
- var buttons = (InputButtons)reader.ReadByte();
- return new ClientInputMessage(sequence, buttons);
+ return new ClientHelloMessage(reader.ReadByte());
}
public static ClientPingMessage ReadPing(ReadOnlySpan source)
@@ -112,16 +112,35 @@ public static class ProtocolCodec
return new ClientPingMessage(reader.ReadInt64());
}
+ public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan source)
+ {
+ var reader = new PacketReader(source);
+ Expect(ref reader, MessageType.ClientOpenSchool);
+ return new ClientOpenSchoolMessage(reader.ReadInt32());
+ }
+
+ public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan source)
+ {
+ var reader = new PacketReader(source);
+ Expect(ref reader, MessageType.ClientSetRunning);
+ return new ClientSetRunningMessage(reader.ReadByte() != 0);
+ }
+
+ public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan source)
+ {
+ var reader = new PacketReader(source);
+ Expect(ref reader, MessageType.ClientSetSpeed);
+ return new ClientSetSpeedMessage(reader.ReadByte());
+ }
+
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerWelcome);
var version = reader.ReadByte();
- var playerEntityId = reader.ReadUInt32();
var tickRate = reader.ReadByte();
- var width = reader.ReadSingle();
- var height = reader.ReadSingle();
- return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
+ var maxSchools = reader.ReadByte();
+ return new ServerWelcomeMessage(version, tickRate, maxSchools);
}
public static ServerPongMessage ReadPong(ReadOnlySpan source)
@@ -133,31 +152,22 @@ public static class ProtocolCodec
return new ServerPongMessage(clientTime, serverTick);
}
- /// Reads a snapshot into and returns the entity count.
- public static int ReadSnapshot(ReadOnlySpan source, Span destination, out uint tick)
+ public static ServerClockMessage ReadClock(ReadOnlySpan source)
{
var reader = new PacketReader(source);
- Expect(ref reader, MessageType.ServerSnapshot);
- tick = reader.ReadUInt32();
- var count = reader.ReadUInt16();
+ Expect(ref reader, MessageType.ServerClock);
+ var schoolId = reader.ReadInt32();
+ var gameTime = reader.ReadInt64();
+ var running = reader.ReadByte() != 0;
+ var speedIndex = reader.ReadByte();
+ return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
+ }
- if (count > destination.Length)
- {
- throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
- }
-
- for (var i = 0; i < count; i++)
- {
- destination[i] = new EntitySnapshot(
- reader.ReadUInt32(),
- (EntityKind)reader.ReadByte(),
- reader.ReadSingle(),
- reader.ReadSingle(),
- reader.ReadSingle(),
- reader.ReadUInt32());
- }
-
- return count;
+ public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan source)
+ {
+ var reader = new PacketReader(source);
+ Expect(ref reader, MessageType.ServerSchoolGone);
+ return new ServerSchoolGoneMessage(reader.ReadInt32());
}
private static void Expect(ref PacketReader reader, MessageType expected)
diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs
index 4bc7c25..1a0e9d7 100644
--- a/src/HSchool.Protocol/ProtocolConstants.cs
+++ b/src/HSchool.Protocol/ProtocolConstants.cs
@@ -1,20 +1,11 @@
-namespace HSchool.Protocol;
-
-/// Wire-format constants shared by the server and the browser client.
-public static class ProtocolConstants
-{
- /// Bumped on every breaking change to the binary layout.
- public const byte Version = 1;
-
- /// Upper bound for a single WebSocket frame accepted by the server.
- public const int MaxMessageSize = 64 * 1024;
-
- /// Bytes of a single entity inside a snapshot payload.
- public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
-
- /// Bytes of the snapshot header: message type + tick + entity count.
- public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
-
- /// Maximum UTF-8 byte length of a player name.
- public const int MaxPlayerNameBytes = 32;
-}
+namespace HSchool.Protocol;
+
+/// Wire-format constants shared by the server and the browser client.
+public static class ProtocolConstants
+{
+ /// Bumped on every breaking change to the binary layout.
+ public const byte Version = 3;
+
+ /// Upper bound for a single WebSocket frame accepted by the server.
+ public const int MaxMessageSize = 8 * 1024;
+}
diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs
new file mode 100644
index 0000000..6166d12
--- /dev/null
+++ b/src/HSchool.Server/Api/SchoolEndpoints.cs
@@ -0,0 +1,111 @@
+using HSchool.Server.Game;
+using HSchool.Simulation;
+
+namespace HSchool.Server.Api;
+
+///
+/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
+/// the loop thread as a command and awaited, so schools stay single-threaded.
+///
+internal static class SchoolEndpoints
+{
+ /// How long a request waits for the loop thread before giving up.
+ private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
+
+ public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
+ {
+ var schools = builder.MapGroup("/api/schools");
+
+ schools.MapGet("/", (GameLoopService loop) =>
+ {
+ var state = loop.SchoolsState;
+ var options = loop.Options;
+
+ return new SchoolsResponse(
+ state.MaxSchools,
+ options.DefaultStartDate,
+ options.GameMinutesPerRealSecond,
+ [.. state.Schools.Select(SchoolResponse.From)]);
+ })
+ .WithName("GetSchools");
+
+ schools.MapGet("/random-name", async (GameCommandQueue commands, CancellationToken cancellationToken) =>
+ {
+ var command = new GameCommand.SuggestName(NewCompletion());
+ commands.Enqueue(command);
+
+ var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return new RandomNameResponse(name);
+ })
+ .WithName("GetRandomSchoolName");
+
+ schools.MapPost("/", async (
+ CreateSchoolRequest request,
+ GameCommandQueue commands,
+ CancellationToken cancellationToken) =>
+ {
+ var command = new GameCommand.CreateSchool(
+ request.Name ?? string.Empty,
+ DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
+ NewCompletion());
+ commands.Enqueue(command);
+
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+
+ return outcome.Error switch
+ {
+ SchoolCreationError.None =>
+ Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
+ SchoolCreationError.LimitReached =>
+ Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
+ SchoolCreationError.InvalidName =>
+ Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
+ SchoolCreationError.InvalidStartDate =>
+ Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
+ _ => Results.Problem("Unknown error."),
+ };
+ })
+ .WithName("CreateSchool");
+
+ schools.MapDelete("/{id:int}", async (
+ int id,
+ GameCommandQueue commands,
+ CancellationToken cancellationToken) =>
+ {
+ var command = new GameCommand.DeleteSchool(id, NewCompletion());
+ commands.Enqueue(command);
+
+ var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return deleted ? Results.NoContent() : Results.NotFound();
+ })
+ .WithName("DeleteSchool");
+ }
+
+ /// The loop thread must never be blocked by a continuation of a waiting request.
+ private static TaskCompletionSource NewCompletion() =>
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private static IResult Problem(int statusCode, string code, string detail) =>
+ Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary
+ {
+ ["code"] = code,
+ });
+}
+
+/// Body of POST /api/schools . The start date is a game calendar date, not a real one.
+internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate);
+
+internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
+{
+ public static SchoolResponse From(SchoolState school) =>
+ new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
+}
+
+/// Everything the main menu needs in one request.
+internal sealed record SchoolsResponse(
+ int MaxSchools,
+ DateTime DefaultStartDate,
+ double GameMinutesPerRealSecond,
+ IReadOnlyList Schools);
+
+internal sealed record RandomNameResponse(string Name);
diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs
index c546b6a..1deb67d 100644
--- a/src/HSchool.Server/Game/GameCommand.cs
+++ b/src/HSchool.Server/Game/GameCommand.cs
@@ -1,20 +1,30 @@
-using HSchool.Protocol;
-
-namespace HSchool.Server.Game;
-
-///
-/// Work item handed from a connection thread to the loop thread. The simulation is
-/// single-threaded, so every mutation arrives as one of these.
-///
-internal abstract record GameCommand
-{
- ///
- /// Spawns an avatar for the connection. The loop completes
- /// with the replication id so the handler can send a Welcome frame.
- ///
- internal sealed record Join(uint PlayerId, TaskCompletionSource EntityId) : GameCommand;
-
- internal sealed record Leave(uint PlayerId) : GameCommand;
-
- internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
-}
+namespace HSchool.Server.Game;
+
+///
+/// Work item handed from a request or connection thread to the loop thread. Schools are
+/// single-threaded, so every mutation and every read of live state arrives as one of these.
+///
+internal abstract record GameCommand
+{
+ internal sealed record CreateSchool(
+ string Name,
+ DateTime StartDate,
+ TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record SuggestName(TaskCompletionSource Result) : GameCommand;
+
+ /// A connection starts watching a school; its calendar starts running.
+ internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
+
+ ///
+ /// Stops watching. The school id travels with the command because the connection may already
+ /// be gone from the registry by the time the loop thread gets here.
+ ///
+ internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
+
+ internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
+
+ internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
+}
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index 9a8d83e..f485306 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -1,153 +1,287 @@
-using System.Diagnostics;
-using HSchool.Protocol;
-using HSchool.Server.Net;
-using HSchool.Simulation;
-using Microsoft.Extensions.Options;
-
-namespace HSchool.Server.Game;
-
-///
-/// Owns the authoritative and drives it at a fixed rate:
-/// drain commands, step the simulation, broadcast a full snapshot.
-/// The world is touched from this thread only.
-///
-internal sealed class GameLoopService(
- IOptions options,
- GameCommandQueue commands,
- ClientRegistry clients,
- GameMetrics metrics,
- ILogger logger) : BackgroundService
-{
- /// Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.
- private const int MaxCatchUpSteps = 5;
-
- private readonly SimulationOptions _options = options.Value;
- private readonly List _snapshotBuffer = [];
- private readonly GameWorld _world = new(options.Value);
-
- private uint _currentTick;
- private int _playerCount;
-
- public uint CurrentTick => Volatile.Read(ref _currentTick);
-
- public int PlayerCount => Volatile.Read(ref _playerCount);
-
- public SimulationOptions Options => _options;
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- logger.LogInformation(
- "Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
- _options.TickRate,
- _options.WorldWidth,
- _options.WorldHeight);
-
- using var timer = new PeriodicTimer(_options.TickInterval);
- var fixedDelta = _options.FixedDeltaTime;
- var lastTimestamp = Stopwatch.GetTimestamp();
- var accumulator = 0d;
-
- try
- {
- while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
- {
- var now = Stopwatch.GetTimestamp();
- accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
- lastTimestamp = now;
-
- DrainCommands();
-
- var steps = 0;
- while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
- {
- var stepStarted = Stopwatch.GetTimestamp();
- _world.Tick();
- metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
-
- accumulator -= fixedDelta;
- steps++;
- }
-
- if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
- {
- logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
- accumulator = 0d;
- }
-
- if (steps > 0)
- {
- Volatile.Write(ref _currentTick, _world.CurrentTick);
- BroadcastSnapshot();
- }
- }
- }
- catch (OperationCanceledException)
- {
- // Normal shutdown.
- }
- finally
- {
- _world.Dispose();
- logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
- }
- }
-
- private void DrainCommands()
- {
- while (commands.TryDequeue(out var command))
- {
- switch (command)
- {
- case GameCommand.Join join:
- HandleJoin(join);
- break;
-
- case GameCommand.Leave leave:
- _world.DespawnPlayer(leave.PlayerId);
- Volatile.Write(ref _playerCount, _world.PlayerCount);
- metrics.PlayerLeft();
- logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
- break;
-
- case GameCommand.Input input:
- _world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
- break;
- }
- }
- }
-
- private void HandleJoin(GameCommand.Join join)
- {
- try
- {
- var entityId = _world.SpawnPlayer(join.PlayerId);
- Volatile.Write(ref _playerCount, _world.PlayerCount);
- metrics.PlayerJoined();
- join.EntityId.TrySetResult(entityId);
- logger.LogInformation(
- "Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
- join.PlayerId,
- entityId,
- _world.PlayerCount);
- }
- catch (Exception ex)
- {
- join.EntityId.TrySetException(ex);
- }
- }
-
- private void BroadcastSnapshot()
- {
- _world.CaptureSnapshot(_snapshotBuffer);
-
- // One immutable buffer is shared by every recipient, so nothing has to be copied per client.
- var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
- var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
-
- var recipients = clients.Broadcast(frame.AsMemory(0, written));
- if (recipients > 0)
- {
- metrics.SnapshotSent(written, recipients);
- }
- }
-}
+using System.Diagnostics;
+using HSchool.Protocol;
+using HSchool.Server.Net;
+using HSchool.Simulation;
+using Microsoft.Extensions.Options;
+
+namespace HSchool.Server.Game;
+
+///
+/// Owns every and drives them at a fixed rate: drain commands, advance the
+/// running calendars, push a clock frame to each connection that has a school open.
+/// Schools are touched from this thread only.
+///
+internal sealed class GameLoopService(
+ IOptions options,
+ GameCommandQueue commands,
+ ClientRegistry clients,
+ GameMetrics metrics,
+ ILogger logger) : BackgroundService
+{
+ /// Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.
+ private const int MaxCatchUpSteps = 5;
+
+ private readonly SimulationOptions _options = options.Value;
+ private readonly SchoolRegistry _schools = new(options.Value);
+
+ private uint _currentTick;
+ private SchoolsState _publishedState = new(options.Value.MaxSchools, []);
+
+ public uint CurrentTick => Volatile.Read(ref _currentTick);
+
+ public SimulationOptions Options => _options;
+
+ ///
+ /// Last state published by the loop thread. Menu requests read this instead of blocking on a
+ /// command; it is at most one tick (50 ms) behind.
+ ///
+ public SchoolsState SchoolsState => Volatile.Read(ref _publishedState);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogInformation(
+ "Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.",
+ _options.TickRate,
+ _options.MaxSchools,
+ _options.GameMinutesPerRealSecond);
+
+ using var timer = new PeriodicTimer(_options.TickInterval);
+ var fixedDelta = _options.FixedDeltaTime;
+ var lastTimestamp = Stopwatch.GetTimestamp();
+ var accumulator = 0d;
+
+ try
+ {
+ while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
+ {
+ var now = Stopwatch.GetTimestamp();
+ accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
+ lastTimestamp = now;
+
+ DrainCommands();
+
+ var steps = 0;
+ while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
+ {
+ var stepStarted = Stopwatch.GetTimestamp();
+ _schools.Tick();
+ Volatile.Write(ref _currentTick, _currentTick + 1);
+ metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
+
+ accumulator -= fixedDelta;
+ steps++;
+ }
+
+ if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
+ {
+ logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
+ accumulator = 0d;
+ }
+
+ if (steps > 0)
+ {
+ PublishState();
+ BroadcastClocks();
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown.
+ }
+ finally
+ {
+ _schools.Dispose();
+ logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick);
+ }
+ }
+
+ private void DrainCommands()
+ {
+ while (commands.TryDequeue(out var command))
+ {
+ switch (command)
+ {
+ case GameCommand.CreateSchool create:
+ HandleCreate(create);
+ break;
+
+ case GameCommand.DeleteSchool delete:
+ HandleDelete(delete);
+ break;
+
+ case GameCommand.SuggestName suggest:
+ Complete(suggest.Result, _schools.SuggestName);
+ break;
+
+ case GameCommand.OpenSchool open:
+ HandleOpen(open);
+ break;
+
+ case GameCommand.CloseSchool close:
+ StopWatching(close.PlayerId, close.SchoolId);
+ break;
+
+ case GameCommand.SetRunning setRunning:
+ WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running);
+ break;
+
+ case GameCommand.SetSpeed setSpeed:
+ WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex);
+ break;
+ }
+ }
+ }
+
+ private void HandleCreate(GameCommand.CreateSchool command)
+ {
+ Complete(command.Result, () =>
+ {
+ var result = _schools.Create(command.Name, command.StartDate);
+ if (!result.Succeeded)
+ {
+ return new SchoolCreationOutcome(null, result.Error);
+ }
+
+ logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name);
+ PublishState();
+
+ return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None);
+ });
+ }
+
+ private void HandleDelete(GameCommand.DeleteSchool command)
+ {
+ Complete(command.Result, () =>
+ {
+ var deleted = _schools.Delete(command.SchoolId);
+ if (!deleted)
+ {
+ return false;
+ }
+
+ // Anyone watching it now stares at a school that no longer exists.
+ foreach (var client in clients.All)
+ {
+ if (client.OpenSchoolId == command.SchoolId)
+ {
+ client.OpenSchoolId = null;
+ SendSchoolGone(client, command.SchoolId);
+ }
+ }
+
+ logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
+ PublishState();
+
+ return true;
+ });
+ }
+
+ private void HandleOpen(GameCommand.OpenSchool command)
+ {
+ var client = clients.Find(command.PlayerId);
+ if (client is null)
+ {
+ return;
+ }
+
+ var school = _schools.Find(command.SchoolId);
+ if (school is null)
+ {
+ SendSchoolGone(client, command.SchoolId);
+ return;
+ }
+
+ client.OpenSchoolId = school.Id;
+
+ logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id);
+ }
+
+ ///
+ /// The connection stops receiving clock frames for that school. The calendar keeps running —
+ /// schools live whether or not somebody is looking at them.
+ ///
+ private void StopWatching(uint playerId, int schoolId)
+ {
+ var client = clients.Find(playerId);
+ if (client?.OpenSchoolId == schoolId)
+ {
+ client.OpenSchoolId = null;
+ }
+ }
+
+ /// Applies a change to the school a connection has open, if it still has one.
+ private void WithOpenSchool(uint playerId, Action change)
+ {
+ var client = clients.Find(playerId);
+ if (client?.OpenSchoolId is not { } schoolId)
+ {
+ return;
+ }
+
+ var school = _schools.Find(schoolId);
+ if (school is not null)
+ {
+ change(school);
+ }
+ }
+
+ private void BroadcastClocks()
+ {
+ foreach (var client in clients.All)
+ {
+ if (!client.IsReady || client.OpenSchoolId is not { } schoolId)
+ {
+ continue;
+ }
+
+ var school = _schools.Find(schoolId);
+ if (school is null)
+ {
+ continue;
+ }
+
+ var frame = new byte[ProtocolCodec.MaxFrameSize];
+ var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
+ school.Id,
+ new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
+ school.Clock.IsRunning,
+ (byte)school.Clock.SpeedIndex));
+
+ client.TrySend(frame.AsMemory(0, length));
+ }
+ }
+
+ private void SendSchoolGone(GameClient client, int schoolId)
+ {
+ var frame = new byte[ProtocolCodec.MaxFrameSize];
+ var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
+ client.TrySend(frame.AsMemory(0, length));
+ }
+
+ private void PublishState()
+ {
+ var snapshot = new SchoolsState(
+ _schools.MaxSchools,
+ _schools.Schools.Select(Capture).ToArray());
+
+ Volatile.Write(ref _publishedState, snapshot);
+ metrics.SchoolsChanged(snapshot.Schools.Count);
+ }
+
+ private static SchoolState Capture(School school) =>
+ new(school.Id, school.Name, school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex);
+
+ /// Runs work for a waiting request thread without letting an exception kill the loop.
+ private static void Complete(TaskCompletionSource completion, Func work)
+ {
+ try
+ {
+ completion.TrySetResult(work());
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetException(ex);
+ }
+ }
+}
diff --git a/src/HSchool.Server/Game/GameMetrics.cs b/src/HSchool.Server/Game/GameMetrics.cs
index 503061d..46b8fcf 100644
--- a/src/HSchool.Server/Game/GameMetrics.cs
+++ b/src/HSchool.Server/Game/GameMetrics.cs
@@ -10,16 +10,17 @@ internal sealed class GameMetrics : IDisposable
private readonly Meter _meter;
private readonly Counter _ticks;
private readonly Histogram _tickDuration;
- private readonly UpDownCounter _connectedPlayers;
- private readonly Counter _snapshotBytes;
+ private readonly UpDownCounter _connections;
+
+ private int _schools;
public GameMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_ticks = _meter.CreateCounter("hschool.game.ticks", "{tick}", "Simulation steps executed.");
_tickDuration = _meter.CreateHistogram("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
- _connectedPlayers = _meter.CreateUpDownCounter("hschool.game.players", "{player}", "Currently connected players.");
- _snapshotBytes = _meter.CreateCounter("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
+ _connections = _meter.CreateUpDownCounter("hschool.game.connections", "{connection}", "Open WebSocket connections.");
+ _meter.CreateObservableGauge("hschool.game.schools", () => Volatile.Read(ref _schools), "{school}", "Schools that currently exist.");
}
public void RecordTick(double durationMs)
@@ -28,11 +29,11 @@ internal sealed class GameMetrics : IDisposable
_tickDuration.Record(durationMs);
}
- public void PlayerJoined() => _connectedPlayers.Add(1);
+ public void ClientConnected() => _connections.Add(1);
- public void PlayerLeft() => _connectedPlayers.Add(-1);
+ public void ClientDisconnected() => _connections.Add(-1);
- public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
+ public void SchoolsChanged(int count) => Volatile.Write(ref _schools, count);
public void Dispose() => _meter.Dispose();
}
diff --git a/src/HSchool.Server/Game/SchoolCreationOutcome.cs b/src/HSchool.Server/Game/SchoolCreationOutcome.cs
new file mode 100644
index 0000000..7028ea6
--- /dev/null
+++ b/src/HSchool.Server/Game/SchoolCreationOutcome.cs
@@ -0,0 +1,9 @@
+using HSchool.Simulation;
+
+namespace HSchool.Server.Game;
+
+/// What the loop thread reports back after trying to create a school.
+internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
+{
+ public bool Succeeded => Error == SchoolCreationError.None && School is not null;
+}
diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs
new file mode 100644
index 0000000..9c5bde2
--- /dev/null
+++ b/src/HSchool.Server/Game/SchoolState.cs
@@ -0,0 +1,10 @@
+namespace HSchool.Server.Game;
+
+///
+/// Immutable copy of a school, safe to hand to request threads. The live School object
+/// never leaves the loop thread.
+///
+internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
+
+/// Everything the main menu needs in one read.
+internal sealed record SchoolsState(int MaxSchools, IReadOnlyList Schools);
diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj
index ee0dc01..484983f 100644
--- a/src/HSchool.Server/HSchool.Server.csproj
+++ b/src/HSchool.Server/HSchool.Server.csproj
@@ -1,16 +1,17 @@
-
-
-
- HSchool.Server
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ HSchool.Server
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HSchool.Server/Net/ClientRegistry.cs b/src/HSchool.Server/Net/ClientRegistry.cs
index 532bce5..ed0ed04 100644
--- a/src/HSchool.Server/Net/ClientRegistry.cs
+++ b/src/HSchool.Server/Net/ClientRegistry.cs
@@ -1,42 +1,28 @@
-using System.Collections.Concurrent;
-using System.Net.WebSockets;
-
-namespace HSchool.Server.Net;
-
-/// Tracks live connections and hands out player ids.
-internal sealed class ClientRegistry
-{
- private readonly ConcurrentDictionary _clients = new();
- private uint _nextPlayerId;
-
- public int Count => _clients.Count;
-
- public GameClient Add(WebSocket socket)
- {
- var playerId = Interlocked.Increment(ref _nextPlayerId);
- var client = new GameClient(playerId, socket);
- _clients[playerId] = client;
- return client;
- }
-
- public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
-
- ///
- /// Queues the same frame for every client that finished its handshake; the buffer must not
- /// be reused afterwards.
- ///
- public int Broadcast(ReadOnlyMemory frame)
- {
- var recipients = 0;
-
- foreach (var client in _clients.Values)
- {
- if (client.IsReady && client.TrySend(frame))
- {
- recipients++;
- }
- }
-
- return recipients;
- }
-}
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+
+namespace HSchool.Server.Net;
+
+/// Tracks live connections and hands out client ids.
+internal sealed class ClientRegistry
+{
+ private readonly ConcurrentDictionary _clients = new();
+ private uint _nextPlayerId;
+
+ public int Count => _clients.Count;
+
+ /// Snapshot-free enumeration; safe because the dictionary is concurrent.
+ public IEnumerable All => _clients.Values;
+
+ public GameClient Add(WebSocket socket)
+ {
+ var playerId = Interlocked.Increment(ref _nextPlayerId);
+ var client = new GameClient(playerId, socket);
+ _clients[playerId] = client;
+ return client;
+ }
+
+ public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
+
+ public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
+}
diff --git a/src/HSchool.Server/Net/GameClient.cs b/src/HSchool.Server/Net/GameClient.cs
index 0da90ff..a5606c8 100644
--- a/src/HSchool.Server/Net/GameClient.cs
+++ b/src/HSchool.Server/Net/GameClient.cs
@@ -4,9 +4,9 @@ using System.Threading.Channels;
namespace HSchool.Server.Net;
///
-/// One connected browser. Frames are queued instead of written inline so a slow client
-/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
-/// which is exactly what you want for state that is resent 20 times a second.
+/// One connected browser. Frames are queued instead of written inline so a slow client can never
+/// stall the game loop; when the outbox overflows the oldest frame is dropped, which is right for
+/// a clock that is resent 20 times a second.
///
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -21,19 +21,33 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
});
private bool _ready;
+ private int _openSchoolId;
public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket;
- public string Name { get; set; } = $"player-{playerId}";
-
///
- /// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
- /// connection never sees world state before it knows its own entity id.
+ /// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a
+ /// connection never sees game state before the handshake finished.
///
public bool IsReady => Volatile.Read(ref _ready);
+ ///
+ /// School this connection is watching, or null in the menu. Written by the loop thread,
+ /// read by the connection thread on disconnect.
+ ///
+ public int? OpenSchoolId
+ {
+ get
+ {
+ // School ids start at 1, so 0 stands for "this client is in the menu".
+ var id = Volatile.Read(ref _openSchoolId);
+ return id == 0 ? null : id;
+ }
+ set => Volatile.Write(ref _openSchoolId, value ?? 0);
+ }
+
public void MarkReady() => Volatile.Write(ref _ready, true);
/// Queues a frame. Returns false once the connection is shutting down.
diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs
index bc07e08..f13a933 100644
--- a/src/HSchool.Server/Net/GameSocketHandler.cs
+++ b/src/HSchool.Server/Net/GameSocketHandler.cs
@@ -1,20 +1,20 @@
using System.Buffers;
using System.Net.WebSockets;
-using System.Text;
using HSchool.Protocol;
using HSchool.Server.Game;
namespace HSchool.Server.Net;
///
-/// Drives one WebSocket connection: handshake, join, then the receive loop.
-/// Everything it learns from the wire is untrusted, so frames are validated before
-/// they reach the simulation.
+/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames
+/// into commands. Everything it reads from the wire is untrusted, so frames are validated before
+/// anything reaches the loop thread.
///
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
+ GameMetrics metrics,
ILogger logger)
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
@@ -26,7 +26,7 @@ internal sealed class GameSocketHandler(
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task? sendLoop = null;
- var joined = false;
+ metrics.ClientConnected();
try
{
@@ -56,19 +56,7 @@ internal sealed class GameSocketHandler(
return;
}
- client.Name = SanitizeName(hello.PlayerName, client.PlayerId);
-
- var join = new GameCommand.Join(
- client.PlayerId,
- new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
- commands.Enqueue(join);
-
- var entityId = await join.EntityId.Task
- .WaitAsync(HandshakeTimeout, connectionCts.Token)
- .ConfigureAwait(false);
- joined = true;
-
- await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
+ await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
// From here on every outbound frame goes through the outbox, so there is
@@ -96,10 +84,13 @@ internal sealed class GameSocketHandler(
ArrayPool.Shared.Return(buffer);
clients.Remove(client.PlayerId);
client.CompleteOutbox();
+ metrics.ClientDisconnected();
- if (joined)
+ // Read after the removal above: an open that lands later finds no client and is
+ // dropped, so this is the last chance to see the school this connection was watching.
+ if (client.OpenSchoolId is { } watchedSchoolId)
{
- commands.Enqueue(new GameCommand.Leave(client.PlayerId));
+ commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, watchedSchoolId));
}
if (sendLoop is not null)
@@ -131,14 +122,31 @@ internal sealed class GameSocketHandler(
var frame = buffer.AsSpan(0, length);
switch (ProtocolCodec.PeekMessageType(frame))
{
- case MessageType.ClientInput:
- var input = ProtocolCodec.ReadInput(frame);
- commands.Enqueue(new GameCommand.Input(client.PlayerId, input.Buttons, input.Sequence));
+ case MessageType.ClientPing:
+ SendPong(client, ProtocolCodec.ReadPing(frame).ClientTimeMs);
break;
- case MessageType.ClientPing:
- var ping = ProtocolCodec.ReadPing(frame);
- SendPong(client, ping.ClientTimeMs);
+ case MessageType.ClientOpenSchool:
+ var open = ProtocolCodec.ReadOpenSchool(frame);
+ commands.Enqueue(new GameCommand.OpenSchool(client.PlayerId, open.SchoolId));
+ break;
+
+ case MessageType.ClientCloseSchool:
+ if (client.OpenSchoolId is { } openSchoolId)
+ {
+ commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, openSchoolId));
+ }
+
+ break;
+
+ case MessageType.ClientSetRunning:
+ var setRunning = ProtocolCodec.ReadSetRunning(frame);
+ commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running));
+ break;
+
+ case MessageType.ClientSetSpeed:
+ var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
+ commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
break;
default:
@@ -190,18 +198,13 @@ internal sealed class GameSocketHandler(
}
}
- private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
+ private async Task SendWelcomeAsync(WebSocket socket, CancellationToken cancellationToken)
{
var options = loop.Options;
- var welcome = new ServerWelcomeMessage(
- ProtocolConstants.Version,
- entityId,
- (byte)options.TickRate,
- options.WorldWidth,
- options.WorldHeight);
-
- var frame = new byte[32];
- var length = ProtocolCodec.WriteWelcome(frame, welcome);
+ var frame = new byte[ProtocolCodec.MaxFrameSize];
+ var length = ProtocolCodec.WriteWelcome(
+ frame,
+ new ServerWelcomeMessage(ProtocolConstants.Version, (byte)options.TickRate, (byte)options.MaxSchools));
await socket
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
@@ -210,7 +213,7 @@ internal sealed class GameSocketHandler(
private void SendPong(GameClient client, long clientTimeMs)
{
- var frame = new byte[16];
+ var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
client.TrySend(frame.AsMemory(0, length));
}
@@ -233,25 +236,4 @@ internal sealed class GameSocketHandler(
}
}
}
-
- /// Names come from the wire: strip control characters and clamp the length.
- private static string SanitizeName(string name, uint playerId)
- {
- var trimmed = name.Trim();
- if (trimmed.Length == 0)
- {
- return $"player-{playerId}";
- }
-
- var builder = new StringBuilder(trimmed.Length);
- foreach (var character in trimmed)
- {
- builder.Append(char.IsControl(character) ? ' ' : character);
- }
-
- var sanitized = builder.ToString();
- return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
- ? sanitized
- : sanitized[..ProtocolConstants.MaxPlayerNameBytes];
- }
}
diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs
index d19cd9a..e298bd8 100644
--- a/src/HSchool.Server/Program.cs
+++ b/src/HSchool.Server/Program.cs
@@ -1,4 +1,5 @@
using System.Net.WebSockets;
+using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -13,7 +14,9 @@ builder.Services
.AddOptions()
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
- .Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
+ .Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
+ .Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
+ .Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
.ValidateOnStart();
builder.Services.AddSingleton();
@@ -39,18 +42,12 @@ app.UseWebSockets(new WebSocketOptions
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
-var api = app.MapGroup("/api");
+app.MapSchoolEndpoints();
-api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
+app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
{
- var options = loop.Options;
- return new GameStatusResponse(
- loop.CurrentTick,
- options.TickRate,
- loop.PlayerCount,
- clients.Count,
- options.WorldWidth,
- options.WorldHeight);
+ var state = loop.SchoolsState;
+ return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count);
})
.WithName("GetGameStatus");
@@ -75,14 +72,8 @@ app.UseFileServer();
app.Run();
-/// Snapshot of loop health for dashboards and integration tests.
-internal sealed record GameStatusResponse(
- uint Tick,
- int TickRate,
- int Players,
- int Connections,
- float WorldWidth,
- float WorldHeight);
+/// Loop health for dashboards and integration tests.
+internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
/// Exposed so WebApplicationFactory -style tests can reference the entry point.
public partial class Program;
diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json
index e542d70..f935ddb 100644
--- a/src/HSchool.Server/appsettings.json
+++ b/src/HSchool.Server/appsettings.json
@@ -1,16 +1,15 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- },
- "AllowedHosts": "*",
- "Simulation": {
- "TickRate": 20,
- "WorldWidth": 1600,
- "WorldHeight": 900,
- "PlayerSpeed": 260,
- "PlayerRadius": 18
- }
-}
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Simulation": {
+ "TickRate": 20,
+ "MaxSchools": 6,
+ "GameMinutesPerRealSecond": 5,
+ "DefaultStartDate": "2012-04-03T06:00:00"
+ }
+}
diff --git a/src/HSchool.Simulation/ClockSpeed.cs b/src/HSchool.Simulation/ClockSpeed.cs
new file mode 100644
index 0000000..19413f5
--- /dev/null
+++ b/src/HSchool.Simulation/ClockSpeed.cs
@@ -0,0 +1,21 @@
+namespace HSchool.Simulation;
+
+///
+/// The speed buttons the player can pick, as an index on the wire. The table is duplicated in
+/// src/HSchool.Client/src/net/protocol.ts — indexes, not multipliers, travel over the socket.
+///
+public static class ClockSpeed
+{
+ /// ×½, ×1, ×2, ×3, ×4.
+ public static ReadOnlySpan Multipliers => [0.5d, 1d, 2d, 3d, 4d];
+
+ /// Index of ×1, the speed a school starts at.
+ public const int DefaultIndex = 1;
+
+ public static int Count => Multipliers.Length;
+
+ public static bool IsValid(int index) => index >= 0 && index < Multipliers.Length;
+
+ /// Multiplier for a validated index; out-of-range values fall back to ×1.
+ public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex];
+}
diff --git a/src/HSchool.Simulation/Components/NetworkId.cs b/src/HSchool.Simulation/Components/NetworkId.cs
deleted file mode 100644
index f9d29f8..0000000
--- a/src/HSchool.Simulation/Components/NetworkId.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace HSchool.Simulation.Components;
-
-///
-/// Stable replication id. Arch entity ids are recycled, so the client gets this
-/// monotonically increasing value instead.
-///
-public struct NetworkId
-{
- public uint Value;
-
- public NetworkId(uint value) => Value = value;
-}
diff --git a/src/HSchool.Simulation/Components/PlayerControl.cs b/src/HSchool.Simulation/Components/PlayerControl.cs
deleted file mode 100644
index d49f13d..0000000
--- a/src/HSchool.Simulation/Components/PlayerControl.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using HSchool.Protocol;
-
-namespace HSchool.Simulation.Components;
-
-/// Marks an entity as driven by a connected client's input.
-public struct PlayerControl
-{
- /// Network id of the owning connection.
- public uint PlayerId;
-
- /// Latest intent received from that connection.
- public InputButtons Buttons;
-
- /// Sequence number of that intent; reserved for prediction/reconciliation.
- public uint LastInputSequence;
-
- /// Movement speed in units per second.
- public float Speed;
-}
diff --git a/src/HSchool.Simulation/Components/Position.cs b/src/HSchool.Simulation/Components/Position.cs
deleted file mode 100644
index 2a8757e..0000000
--- a/src/HSchool.Simulation/Components/Position.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace HSchool.Simulation.Components;
-
-/// World-space position in simulation units.
-public struct Position
-{
- public float X;
- public float Y;
-
- public Position(float x, float y)
- {
- X = x;
- Y = y;
- }
-}
diff --git a/src/HSchool.Simulation/Components/Renderable.cs b/src/HSchool.Simulation/Components/Renderable.cs
deleted file mode 100644
index 5522483..0000000
--- a/src/HSchool.Simulation/Components/Renderable.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using HSchool.Protocol;
-
-namespace HSchool.Simulation.Components;
-
-/// Everything the client needs to draw the entity; replicated verbatim in snapshots.
-public struct Renderable
-{
- public EntityKind Kind;
- public float Radius;
-
- /// Packed 0x00RRGGBB.
- public uint Color;
-}
diff --git a/src/HSchool.Simulation/Components/Velocity.cs b/src/HSchool.Simulation/Components/Velocity.cs
deleted file mode 100644
index eaf5a80..0000000
--- a/src/HSchool.Simulation/Components/Velocity.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace HSchool.Simulation.Components;
-
-/// Simulation units per second, integrated by MovementSystem .
-public struct Velocity
-{
- public float X;
- public float Y;
-
- public Velocity(float x, float y)
- {
- X = x;
- Y = y;
- }
-}
diff --git a/src/HSchool.Simulation/GameClock.cs b/src/HSchool.Simulation/GameClock.cs
new file mode 100644
index 0000000..10d56ec
--- /dev/null
+++ b/src/HSchool.Simulation/GameClock.cs
@@ -0,0 +1,67 @@
+namespace HSchool.Simulation;
+
+///
+/// In-game calendar of one school. Time only moves while is set, and it
+/// moves by whole fixed steps — never by wall-clock deltas — so the same tick count always
+/// produces the same date.
+///
+public sealed class GameClock
+{
+ /// Earliest date a school may start at; anything below is a typo, not a design choice.
+ public static readonly DateTime MinStartDate = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ public static readonly DateTime MaxStartDate = new(2999, 12, 31, 23, 59, 59, DateTimeKind.Utc);
+
+ private int _speedIndex = ClockSpeed.DefaultIndex;
+
+ public GameClock(DateTime startDate)
+ {
+ if (!IsValidStartDate(startDate))
+ {
+ throw new ArgumentOutOfRangeException(nameof(startDate), startDate, "Start date is outside the supported range.");
+ }
+
+ // The game calendar is not tied to a real time zone; UTC keeps serialization unambiguous.
+ Time = DateTime.SpecifyKind(startDate, DateTimeKind.Utc);
+ }
+
+ public DateTime Time { get; private set; }
+
+ ///
+ /// Schools live on their own: a new calendar starts running and only the player's pause
+ /// button stops it. Leaving for the menu does not.
+ ///
+ public bool IsRunning { get; set; } = true;
+
+ /// Index into ; invalid values are ignored.
+ public int SpeedIndex
+ {
+ get => _speedIndex;
+ set
+ {
+ if (ClockSpeed.IsValid(value))
+ {
+ _speedIndex = value;
+ }
+ }
+ }
+
+ public double Multiplier => ClockSpeed.MultiplierAt(_speedIndex);
+
+ public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
+
+ ///
+ /// Advances the calendar by one fixed step of , scaled by the
+ /// base rate and the current speed. Does nothing while paused.
+ ///
+ public void Advance(double realSeconds, double gameMinutesPerRealSecond)
+ {
+ if (!IsRunning)
+ {
+ return;
+ }
+
+ var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
+ Time = Time.AddMinutes(gameMinutes);
+ }
+}
diff --git a/src/HSchool.Simulation/GameWorld.cs b/src/HSchool.Simulation/GameWorld.cs
deleted file mode 100644
index 3a6bbad..0000000
--- a/src/HSchool.Simulation/GameWorld.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-using Arch.Core;
-using HSchool.Protocol;
-using HSchool.Simulation.Components;
-using HSchool.Simulation.Systems;
-
-namespace HSchool.Simulation;
-
-///
-/// The authoritative world: an Arch plus the fixed-step system pipeline.
-/// Not thread-safe by design — only the game loop thread may touch it, everything else
-/// goes through the command queue in the server layer.
-///
-public sealed class GameWorld : IDisposable
-{
- private readonly World _world;
- private readonly ISimulationSystem[] _systems;
- private readonly Dictionary _playerEntities = [];
-
- private uint _nextNetworkId = 1;
- private bool _disposed;
-
- public GameWorld(SimulationOptions? options = null)
- {
- Options = options ?? new SimulationOptions();
- _world = World.Create();
- _systems =
- [
- new PlayerInputSystem(),
- new MovementSystem(),
- new WorldBoundsSystem(),
- ];
-
- SpawnObstacles();
- }
-
- public SimulationOptions Options { get; }
-
- /// Number of fixed steps simulated so far.
- public uint CurrentTick { get; private set; }
-
- public int PlayerCount => _playerEntities.Count;
-
- public int EntityCount => _world.CountEntities(new QueryDescription().WithAll());
-
- /// Adds a player body. Returns its replication id.
- public uint SpawnPlayer(uint playerId)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_playerEntities.ContainsKey(playerId))
- {
- throw new InvalidOperationException($"Player {playerId} is already spawned.");
- }
-
- var networkId = _nextNetworkId++;
- var (x, y) = SpawnPoint(playerId);
-
- var entity = _world.Create(
- new NetworkId(networkId),
- new Position(x, y),
- new Velocity(0f, 0f),
- new PlayerControl
- {
- PlayerId = playerId,
- Buttons = InputButtons.None,
- Speed = Options.PlayerSpeed,
- },
- new Renderable
- {
- Kind = EntityKind.Player,
- Radius = Options.PlayerRadius,
- Color = Palette.ForPlayer(playerId),
- });
-
- _playerEntities[playerId] = entity;
- return networkId;
- }
-
- public void DespawnPlayer(uint playerId)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_playerEntities.Remove(playerId, out var entity) && _world.IsAlive(entity))
- {
- _world.Destroy(entity);
- }
- }
-
- /// Stores the latest intent for a player; applied on the next tick.
- public void ApplyInput(uint playerId, InputButtons buttons, uint sequence)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (!_playerEntities.TryGetValue(playerId, out var entity) || !_world.IsAlive(entity))
- {
- return;
- }
-
- ref var control = ref _world.Get(entity);
-
- // Late/duplicate packets carry a stale sequence; the newest intent wins.
- if (sequence < control.LastInputSequence)
- {
- return;
- }
-
- control.Buttons = buttons;
- control.LastInputSequence = sequence;
- }
-
- /// Runs one fixed step of the pipeline.
- public void Tick()
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- CurrentTick++;
- var context = new SimulationContext(CurrentTick, Options.FixedDeltaTime, Options);
-
- foreach (var system in _systems)
- {
- system.Update(_world, in context);
- }
- }
-
- /// Fills with the replicated state of every visible entity.
- public void CaptureSnapshot(List buffer)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- ArgumentNullException.ThrowIfNull(buffer);
-
- buffer.Clear();
-
- var query = new QueryDescription().WithAll();
- _world.Query(in query, (ref NetworkId id, ref Position position, ref Renderable renderable) =>
- {
- buffer.Add(new EntitySnapshot(
- id.Value,
- renderable.Kind,
- position.X,
- position.Y,
- renderable.Radius,
- renderable.Color));
- });
- }
-
- /// Replication id of a connected player, or null if it is not spawned.
- public uint? GetNetworkId(uint playerId) =>
- _playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
- ? _world.Get(entity).Value
- : null;
-
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- World.Destroy(_world);
- }
-
- /// A few static blocks so an empty world still shows something on screen.
- private void SpawnObstacles()
- {
- ReadOnlySpan<(float X, float Y, float Radius)> layout =
- [
- (0.5f, 0.5f, 70f),
- (0.2f, 0.25f, 45f),
- (0.8f, 0.75f, 45f),
- ];
-
- foreach (var (relativeX, relativeY, radius) in layout)
- {
- _world.Create(
- new NetworkId(_nextNetworkId++),
- new Position(Options.WorldWidth * relativeX, Options.WorldHeight * relativeY),
- new Renderable
- {
- Kind = EntityKind.Obstacle,
- Radius = radius,
- Color = Palette.Obstacle,
- });
- }
- }
-
- /// Deterministic spread of spawn points around the centre of the field.
- private (float X, float Y) SpawnPoint(uint playerId)
- {
- const int Slots = 8;
- var slot = (int)(playerId % Slots);
- var angle = slot * (2f * MathF.PI / Slots);
- var radius = MathF.Min(Options.WorldWidth, Options.WorldHeight) * 0.3f;
-
- return (
- (Options.WorldWidth * 0.5f) + (MathF.Cos(angle) * radius),
- (Options.WorldHeight * 0.5f) + (MathF.Sin(angle) * radius));
- }
-}
diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj
index 9e0b87d..a787ba8 100644
--- a/src/HSchool.Simulation/HSchool.Simulation.csproj
+++ b/src/HSchool.Simulation/HSchool.Simulation.csproj
@@ -1,16 +1,12 @@
-
-
-
- HSchool.Simulation
- true
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ HSchool.Simulation
+ true
+
+
+
+
+
+
+
diff --git a/src/HSchool.Simulation/ISimulationSystem.cs b/src/HSchool.Simulation/ISimulationSystem.cs
deleted file mode 100644
index 9a7067d..0000000
--- a/src/HSchool.Simulation/ISimulationSystem.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Arch.Core;
-
-namespace HSchool.Simulation;
-
-///
-/// One stage of the fixed-step pipeline. Systems run in registration order on the
-/// loop thread and must not capture per-step state.
-///
-public interface ISimulationSystem
-{
- void Update(World world, in SimulationContext context);
-}
diff --git a/src/HSchool.Simulation/Palette.cs b/src/HSchool.Simulation/Palette.cs
deleted file mode 100644
index fce3578..0000000
--- a/src/HSchool.Simulation/Palette.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace HSchool.Simulation;
-
-/// Stable colours for replicated entities, packed as 0x00RRGGBB.
-public static class Palette
-{
- public const uint Obstacle = 0x3A4553;
-
- private static readonly uint[] PlayerColors =
- [
- 0x4CC9F0,
- 0xF72585,
- 0x7BF1A8,
- 0xFFB703,
- 0xB388EB,
- 0xFF7A5C,
- 0x5CE1E6,
- 0xE9FF70,
- ];
-
- /// Same player id always gets the same colour, on both server and client.
- public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
-}
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
new file mode 100644
index 0000000..c082c56
--- /dev/null
+++ b/src/HSchool.Simulation/School.cs
@@ -0,0 +1,54 @@
+using Arch.Core;
+
+namespace HSchool.Simulation;
+
+///
+/// One save: a name, a calendar and the ECS world that will hold everything the school is made of.
+/// The world is empty for now — 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.
+///
+public sealed class School : IDisposable
+{
+ /// Longest name a school may carry, in characters.
+ public const int MaxNameLength = 40;
+
+ private bool _disposed;
+
+ internal School(int id, string name, DateTime startDate)
+ {
+ Id = id;
+ Name = name;
+ Clock = new GameClock(startDate);
+ World = World.Create();
+ }
+
+ public int Id { get; }
+
+ public string Name { get; }
+
+ public GameClock Clock { get; }
+
+ /// The Arch world backing this school. Only the loop thread may touch it.
+ public World World { get; }
+
+ /// Runs one fixed step of the school. Today that is only the calendar.
+ public void Tick(double deltaTime, double gameMinutesPerRealSecond)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ Clock.Advance(deltaTime, gameMinutesPerRealSecond);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // Fully qualified: the `World` property would otherwise shadow the type.
+ Arch.Core.World.Destroy(World);
+ }
+}
diff --git a/src/HSchool.Simulation/SchoolNameGenerator.cs b/src/HSchool.Simulation/SchoolNameGenerator.cs
new file mode 100644
index 0000000..3ed77e9
--- /dev/null
+++ b/src/HSchool.Simulation/SchoolNameGenerator.cs
@@ -0,0 +1,57 @@
+namespace HSchool.Simulation;
+
+///
+/// Suggestions for the "random name" button. Lives here rather than in the client because the
+/// server is the one that knows which names are already taken.
+///
+public sealed class SchoolNameGenerator(Random? random = null)
+{
+ private const int AttemptsBeforeNumbering = 24;
+
+ private static readonly string[] Kinds = ["Школа", "Гимназия", "Лицей", "Школа-интернат"];
+
+ private static readonly string[] Epithets =
+ [
+ "Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная",
+ "Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная",
+ ];
+
+ private readonly Random _random = random ?? Random.Shared;
+
+ ///
+ /// A name that is not in . Falls back to a numbered name so the
+ /// button always produces something, even when the pool is exhausted.
+ ///
+ public string Next(IEnumerable taken)
+ {
+ var used = new HashSet(taken, StringComparer.OrdinalIgnoreCase);
+
+ for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++)
+ {
+ var candidate = Compose();
+ if (used.Add(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ for (var number = 1; ; number++)
+ {
+ var candidate = $"Школа №{number}";
+ if (!used.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+ }
+
+ private string Compose()
+ {
+ var kind = Kinds[_random.Next(Kinds.Length)];
+
+ // Half the names are numbered, half are named — both read like a real school.
+ return _random.Next(2) == 0
+ ? $"{kind} №{_random.Next(1, 100)}"
+ : $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»";
+ }
+}
diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs
new file mode 100644
index 0000000..b6ff620
--- /dev/null
+++ b/src/HSchool.Simulation/SchoolRegistry.cs
@@ -0,0 +1,141 @@
+namespace HSchool.Simulation;
+
+/// Why a school could not be created.
+public enum SchoolCreationError
+{
+ None = 0,
+ LimitReached,
+ InvalidName,
+ InvalidStartDate,
+}
+
+/// Outcome of : either the school or the reason there is none.
+public readonly record struct SchoolCreationResult(School? School, SchoolCreationError Error)
+{
+ public bool Succeeded => Error == SchoolCreationError.None && School is not null;
+
+ public static SchoolCreationResult Failed(SchoolCreationError error) => new(null, error);
+}
+
+///
+/// Every school that currently exists, plus the cap from configuration. Not thread-safe by design —
+/// only the loop thread touches it, everything else goes through the command queue in the server.
+///
+public sealed class SchoolRegistry : IDisposable
+{
+ private readonly SimulationOptions _options;
+ private readonly List _schools = [];
+
+ private int _nextId = 1;
+ private bool _disposed;
+
+ public SchoolRegistry(SimulationOptions options)
+ {
+ _options = options;
+ NameGenerator = new SchoolNameGenerator();
+ }
+
+ public SchoolNameGenerator NameGenerator { get; }
+
+ public int MaxSchools => _options.MaxSchools;
+
+ public int Count => _schools.Count;
+
+ public bool IsFull => _schools.Count >= _options.MaxSchools;
+
+ /// Schools in creation order — the order the menu lists them in.
+ public IReadOnlyList Schools => _schools;
+
+ public School? Find(int id) => _schools.Find(school => school.Id == id);
+
+ public SchoolCreationResult Create(string name, DateTime startDate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (IsFull)
+ {
+ return SchoolCreationResult.Failed(SchoolCreationError.LimitReached);
+ }
+
+ if (!TryNormalizeName(name, out var normalized))
+ {
+ return SchoolCreationResult.Failed(SchoolCreationError.InvalidName);
+ }
+
+ if (!GameClock.IsValidStartDate(startDate))
+ {
+ return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
+ }
+
+ var school = new School(_nextId++, normalized, startDate);
+ _schools.Add(school);
+
+ return new SchoolCreationResult(school, SchoolCreationError.None);
+ }
+
+ public bool Delete(int id)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var school = Find(id);
+ if (school is null)
+ {
+ return false;
+ }
+
+ _schools.Remove(school);
+ school.Dispose();
+ return true;
+ }
+
+ /// Advances every running school by one fixed step.
+ public void Tick()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ foreach (var school in _schools)
+ {
+ school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
+ }
+ }
+
+ /// A name the player has not used yet, for the "random" button in the creation form.
+ public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name));
+
+ /// Trims, strips control characters and enforces the length limit.
+ public static bool TryNormalizeName(string? name, out string normalized)
+ {
+ normalized = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return false;
+ }
+
+ var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
+ if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength)
+ {
+ return false;
+ }
+
+ normalized = cleaned;
+ return true;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ foreach (var school in _schools)
+ {
+ school.Dispose();
+ }
+
+ _schools.Clear();
+ }
+}
diff --git a/src/HSchool.Simulation/SimulationContext.cs b/src/HSchool.Simulation/SimulationContext.cs
deleted file mode 100644
index c05b385..0000000
--- a/src/HSchool.Simulation/SimulationContext.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace HSchool.Simulation;
-
-/// Per-step data handed to every system.
-/// Index of the step being simulated.
-/// Fixed step length in seconds.
-/// Simulation tunables.
-public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs
index 3adf433..1bf46d5 100644
--- a/src/HSchool.Simulation/SimulationOptions.cs
+++ b/src/HSchool.Simulation/SimulationOptions.cs
@@ -1,23 +1,33 @@
-namespace HSchool.Simulation;
-
-/// Tunables of the authoritative simulation. Bound from the Simulation config section.
-public sealed class SimulationOptions
-{
- public const string SectionName = "Simulation";
-
- /// Fixed simulation steps per second.
- public int TickRate { get; set; } = 20;
-
- public float WorldWidth { get; set; } = 1600f;
-
- public float WorldHeight { get; set; } = 900f;
-
- public float PlayerSpeed { get; set; } = 260f;
-
- public float PlayerRadius { get; set; } = 18f;
-
- /// Length of one fixed step.
- public float FixedDeltaTime => 1f / TickRate;
-
- public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
-}
+namespace HSchool.Simulation;
+
+/// Tunables of the authoritative simulation. Bound from the Simulation config section.
+public sealed class SimulationOptions
+{
+ public const string SectionName = "Simulation";
+
+ private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ /// Fixed simulation steps per second.
+ public int TickRate { get; set; } = 20;
+
+ /// How many schools may exist at the same time.
+ public int MaxSchools { get; set; } = 6;
+
+ /// Base speed of the game clock: real seconds are multiplied by this many game minutes.
+ public double GameMinutesPerRealSecond { get; set; } = 5d;
+
+ /// Prefilled start of a new school; the client shows it in the creation form.
+ public DateTime DefaultStartDate
+ {
+ get => _defaultStartDate;
+
+ // Configuration binding yields Kind=Unspecified, which serializes without a "Z" and makes
+ // the browser read the date in its own time zone. The game calendar is always UTC.
+ set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc);
+ }
+
+ /// Length of one fixed step.
+ public double FixedDeltaTime => 1d / TickRate;
+
+ public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
+}
diff --git a/src/HSchool.Simulation/Systems/MovementSystem.cs b/src/HSchool.Simulation/Systems/MovementSystem.cs
deleted file mode 100644
index 420b939..0000000
--- a/src/HSchool.Simulation/Systems/MovementSystem.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Arch.Core;
-using HSchool.Simulation.Components;
-
-namespace HSchool.Simulation.Systems;
-
-/// Integrates velocity into position with the fixed step.
-public sealed class MovementSystem : ISimulationSystem
-{
- private static readonly QueryDescription Query =
- new QueryDescription().WithAll();
-
- public void Update(World world, in SimulationContext context)
- {
- var deltaTime = context.DeltaTime;
-
- world.Query(in Query, (ref Position position, ref Velocity velocity) =>
- {
- position.X += velocity.X * deltaTime;
- position.Y += velocity.Y * deltaTime;
- });
- }
-}
diff --git a/src/HSchool.Simulation/Systems/PlayerInputSystem.cs b/src/HSchool.Simulation/Systems/PlayerInputSystem.cs
deleted file mode 100644
index c854c13..0000000
--- a/src/HSchool.Simulation/Systems/PlayerInputSystem.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using Arch.Core;
-using HSchool.Protocol;
-using HSchool.Simulation.Components;
-
-namespace HSchool.Simulation.Systems;
-
-/// Turns the latest button mask of every player into a velocity vector.
-public sealed class PlayerInputSystem : ISimulationSystem
-{
- private static readonly QueryDescription Query =
- new QueryDescription().WithAll();
-
- public void Update(World world, in SimulationContext context)
- {
- world.Query(in Query, (ref PlayerControl control, ref Velocity velocity) =>
- {
- var x = 0f;
- var y = 0f;
-
- if ((control.Buttons & InputButtons.Left) != 0) x -= 1f;
- if ((control.Buttons & InputButtons.Right) != 0) x += 1f;
- if ((control.Buttons & InputButtons.Up) != 0) y -= 1f;
- if ((control.Buttons & InputButtons.Down) != 0) y += 1f;
-
- // Normalize so diagonals are not faster than the cardinal directions.
- if (x != 0f && y != 0f)
- {
- const float InverseSqrt2 = 0.70710678f;
- x *= InverseSqrt2;
- y *= InverseSqrt2;
- }
-
- velocity.X = x * control.Speed;
- velocity.Y = y * control.Speed;
- });
- }
-}
diff --git a/src/HSchool.Simulation/Systems/WorldBoundsSystem.cs b/src/HSchool.Simulation/Systems/WorldBoundsSystem.cs
deleted file mode 100644
index 3e507cd..0000000
--- a/src/HSchool.Simulation/Systems/WorldBoundsSystem.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Arch.Core;
-using HSchool.Simulation.Components;
-
-namespace HSchool.Simulation.Systems;
-
-/// Keeps every body inside the play field and kills the velocity it pushed with.
-public sealed class WorldBoundsSystem : ISimulationSystem
-{
- private static readonly QueryDescription Query =
- new QueryDescription().WithAll();
-
- public void Update(World world, in SimulationContext context)
- {
- var width = context.Options.WorldWidth;
- var height = context.Options.WorldHeight;
-
- world.Query(in Query, (ref Position position, ref Velocity velocity, ref Renderable renderable) =>
- {
- var minX = renderable.Radius;
- var maxX = width - renderable.Radius;
- var minY = renderable.Radius;
- var maxY = height - renderable.Radius;
-
- if (position.X < minX)
- {
- position.X = minX;
- velocity.X = 0f;
- }
- else if (position.X > maxX)
- {
- position.X = maxX;
- velocity.X = 0f;
- }
-
- if (position.Y < minY)
- {
- position.Y = minY;
- velocity.Y = 0f;
- }
- else if (position.Y > maxY)
- {
- position.Y = maxY;
- velocity.Y = 0f;
- }
- });
- }
-}
diff --git a/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs b/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs
deleted file mode 100644
index d1ed321..0000000
--- a/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs
+++ /dev/null
@@ -1,243 +0,0 @@
-using System.Net.WebSockets;
-using System.Text.Json;
-using HSchool.Protocol;
-
-namespace HSchool.AppHost.Tests;
-
-///
-/// Talks to the running server exactly the way the browser client does: binary frames over
-/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
-///
-[Collection(AppHostCollection.Name)]
-public class GameServerIntegrationTests(AppHostFixture fixture)
-{
- private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
-
- private DistributedApplication App => fixture.App;
-
- [Fact]
- public async Task HealthEndpoint_ReportsHealthy()
- {
- using var client = App.CreateHttpClient("server");
-
- using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
-
- Assert.Equal(HttpStatusCode.OK, response.StatusCode);
- }
-
- [Fact]
- public async Task StatusEndpoint_ReportsARunningLoop()
- {
- using var client = App.CreateHttpClient("server");
-
- using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
- response.EnsureSuccessStatusCode();
-
- var status = JsonSerializer.Deserialize(
- await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
- JsonSerializerOptions.Web);
-
- Assert.NotNull(status);
- Assert.Equal(20, status.TickRate);
- Assert.True(status.WorldWidth > 0);
-
- // The loop runs on its own thread; give it a moment to produce a tick.
- await WaitUntilAsync(
- async () => (await GetStatusAsync(client)).Tick > 0,
- TimeSpan.FromSeconds(5));
- }
-
- [Fact]
- public async Task Handshake_AnswersWithAWelcomeFrame()
- {
- using var socket = await ConnectAsync();
-
- var welcome = await ReceiveWelcomeAsync(socket);
-
- Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
- Assert.Equal(20, welcome.TickRate);
- Assert.True(welcome.PlayerEntityId > 0);
- }
-
- [Fact]
- public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
- {
- using var socket = await ConnectAsync();
- var welcome = await ReceiveWelcomeAsync(socket);
-
- var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
-
- Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
- }
-
- [Fact]
- public async Task Input_MovesThePlayerOnTheServer()
- {
- using var socket = await ConnectAsync();
- var welcome = await ReceiveWelcomeAsync(socket);
-
- var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
- var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
-
- // Hold "right" for a few ticks, draining snapshots so the socket never backs up.
- var sequence = 0u;
- var lastX = startX;
- for (var i = 0; i < 20; i++)
- {
- await SendAsync(socket, buffer =>
- ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
-
- var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
- lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
- }
-
- Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
- }
-
- [Fact]
- public async Task Ping_IsAnsweredWithTheSameTimestamp()
- {
- using var socket = await ConnectAsync();
- await ReceiveWelcomeAsync(socket);
-
- const long ClientTime = 1_700_000_000_123;
- await SendAsync(socket, buffer =>
- ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
-
- var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
-
- Assert.Equal(ClientTime, ProtocolCodec.ReadPong(pong).ClientTimeMs);
- }
-
- [Fact]
- public async Task VersionMismatch_IsRejected()
- {
- using var socket = await ConnectRawAsync();
-
- await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
- buffer,
- new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), "stale-client")));
-
- var buffer = new byte[ProtocolConstants.MaxMessageSize];
- var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
-
- Assert.Equal(WebSocketMessageType.Close, result.MessageType);
- Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
- }
-
- private async Task ConnectAsync(string playerName = "integration-test")
- {
- var socket = await ConnectRawAsync();
- await SendAsync(socket, buffer =>
- ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
- return socket;
- }
-
- private async Task ConnectRawAsync()
- {
- var http = App.GetEndpoint("server", "http");
- var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
-
- var socket = new ClientWebSocket();
- await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
- return socket;
- }
-
- private static async Task SendAsync(WebSocket socket, Func write)
- {
- var buffer = new byte[64];
- var length = write(buffer);
-
- await socket.SendAsync(
- buffer.AsMemory(0, length),
- WebSocketMessageType.Binary,
- endOfMessage: true,
- TestContext.Current.CancellationToken);
- }
-
- private static async Task ReceiveWelcomeAsync(WebSocket socket) =>
- ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
-
- private static async Task ReceiveSnapshotAsync(WebSocket socket)
- {
- var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
-
- var entities = new EntitySnapshot[ushort.MaxValue];
- var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
-
- return entities[..count];
- }
-
- ///
- /// Reads snapshots until the given entity shows up. The very first snapshot after a join can
- /// still describe the tick before the spawn was applied.
- ///
- private static async Task ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
- {
- for (var attempt = 0; attempt < 10; attempt++)
- {
- var entities = await ReceiveSnapshotAsync(socket);
- if (Array.Exists(entities, entity => entity.Id == entityId))
- {
- return entities;
- }
- }
-
- throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
- }
-
- /// Reads frames until one of shows up.
- private static async Task ReceiveUntilAsync(WebSocket socket, MessageType expected)
- {
- using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
- timeout.CancelAfter(DefaultTimeout);
-
- var buffer = new byte[ProtocolConstants.MaxMessageSize];
-
- while (true)
- {
- var result = await socket.ReceiveAsync(buffer, timeout.Token);
- if (result.MessageType == WebSocketMessageType.Close)
- {
- throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
- }
-
- var frame = buffer[..result.Count];
- if (ProtocolCodec.PeekMessageType(frame) == expected)
- {
- return frame;
- }
- }
- }
-
- private static async Task GetStatusAsync(HttpClient client)
- {
- var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
- return JsonSerializer.Deserialize(json, JsonSerializerOptions.Web)!;
- }
-
- private static async Task WaitUntilAsync(Func> condition, TimeSpan timeout)
- {
- var deadline = DateTime.UtcNow + timeout;
-
- while (DateTime.UtcNow < deadline)
- {
- if (await condition())
- {
- return;
- }
-
- await Task.Delay(100, TestContext.Current.CancellationToken);
- }
-
- Assert.Fail($"Condition was not met within {timeout}.");
- }
-
- private sealed record StatusResponse(
- uint Tick,
- int TickRate,
- int Players,
- int Connections,
- float WorldWidth,
- float WorldHeight);
-}
diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs
new file mode 100644
index 0000000..f501b9a
--- /dev/null
+++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs
@@ -0,0 +1,357 @@
+using System.Net.WebSockets;
+using HSchool.Protocol;
+
+namespace HSchool.AppHost.Tests;
+
+///
+/// Talks to the realtime channel the way the browser does: binary frames over a WebSocket.
+/// The clock only moves while a connection has the school open, which is what these assert.
+///
+[Collection(AppHostCollection.Name)]
+public class GameSocketTests(AppHostFixture fixture)
+{
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
+ private static readonly DateTime StartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task Handshake_AnswersWithAWelcomeFrame()
+ {
+ using var socket = await ConnectAsync();
+
+ var welcome = ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
+
+ Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
+ Assert.Equal(20, welcome.TickRate);
+ Assert.Equal(6, welcome.MaxSchools);
+ }
+
+ [Fact]
+ public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
+
+ using var socket = await OpenSchoolAsync(school.Id);
+ await ReceiveClockAsync(socket);
+
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
+ var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
+
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 3)));
+ var afterSpeedChange = await ReceiveClockWhereAsync(socket, clock => clock.SpeedIndex == 3);
+ var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
+
+ Assert.False(afterSpeedChange.Running);
+ Assert.False(later.Running);
+ Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
+ }
+
+ [Fact]
+ public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
+
+ using var socket = await ConnectAsync();
+ await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
+
+ // A connection that opened nothing gets no clock frames…
+ await Assert.ThrowsAsync(() =>
+ ReceiveUntilAsync(socket, MessageType.ServerClock, TimeSpan.FromSeconds(1)));
+
+ // …but the school moved on anyway, which is what the menu cards show.
+ var reloaded = await FindAsync(client, school.Id);
+ Assert.True(reloaded.Running);
+ Assert.True(reloaded.GameTime > StartDate, $"The calendar stood still at {reloaded.GameTime:O}.");
+ }
+
+ [Fact]
+ public async Task OpeningASchool_StreamsItsClock()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
+
+ using var socket = await OpenSchoolAsync(school.Id);
+ var first = await ReceiveClockAsync(socket);
+
+ Assert.Equal(school.Id, first.SchoolId);
+ Assert.True(first.Running);
+ Assert.Equal(1, first.SpeedIndex);
+
+ // 5 game minutes per real second at ×1, so one second of ticks has to move the calendar.
+ var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
+ var elapsed = ToDate(later) - ToDate(first);
+
+ Assert.InRange(elapsed.TotalMinutes, 3, 8);
+ }
+
+ [Fact]
+ public async Task Pausing_FreezesTheClock()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
+
+ using var socket = await OpenSchoolAsync(school.Id);
+ await ReceiveClockAsync(socket);
+
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
+
+ var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
+ var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
+
+ Assert.False(later.Running);
+ Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
+ }
+
+ [Fact]
+ public async Task SpeedIndex_ChangesHowFastTheCalendarMoves()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
+
+ using var socket = await OpenSchoolAsync(school.Id);
+ await ReceiveClockAsync(socket);
+
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteSetSpeed(buffer, new ClientSetSpeedMessage(SpeedIndex: 4)));
+
+ var fast = await ReceiveClockWhereAsync(socket, clock => clock.SpeedIndex == 4);
+ var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
+ var elapsed = ToDate(later) - ToDate(fast);
+
+ // ×4 means 20 game minutes per real second.
+ Assert.InRange(elapsed.TotalMinutes, 12, 30);
+ }
+
+ [Fact]
+ public async Task LeavingASchool_KeepsItsClockRunning()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
+
+ using (var socket = await OpenSchoolAsync(school.Id))
+ {
+ await ReceiveClockAsync(socket);
+ await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
+ }
+
+ var afterLeaving = await FindAsync(client, school.Id);
+ await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
+ var later = await FindAsync(client, school.Id);
+
+ Assert.True(later.Running);
+ Assert.True(later.GameTime > afterLeaving.GameTime, "The calendar stopped when the client left.");
+ }
+
+ [Fact]
+ public async Task APausedSchool_StaysPausedAfterLeaving()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
+
+ using (var socket = await OpenSchoolAsync(school.Id))
+ {
+ await ReceiveClockAsync(socket);
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
+ await ReceiveClockWhereAsync(socket, clock => !clock.Running);
+ await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
+ }
+
+ var afterLeaving = await FindAsync(client, school.Id);
+ await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
+ var later = await FindAsync(client, school.Id);
+
+ Assert.False(later.Running);
+ Assert.Equal(afterLeaving.GameTime, later.GameTime);
+ }
+
+ [Fact]
+ public async Task DeletingTheOpenSchool_TellsTheClient()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
+
+ using var socket = await OpenSchoolAsync(school.Id);
+ await ReceiveClockAsync(socket);
+
+ using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
+ deleted.EnsureSuccessStatusCode();
+
+ var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
+
+ Assert.Equal(school.Id, gone.SchoolId);
+ }
+
+ [Fact]
+ public async Task OpeningASchoolThatDoesNotExist_IsAnsweredWithSchoolGone()
+ {
+ using var socket = await ConnectAsync();
+ await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
+
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(999999)));
+
+ var gone = ProtocolCodec.ReadSchoolGone(await ReceiveUntilAsync(socket, MessageType.ServerSchoolGone));
+
+ Assert.Equal(999999, gone.SchoolId);
+ }
+
+ [Fact]
+ public async Task Ping_IsAnsweredWithTheSameTimestamp()
+ {
+ using var socket = await ConnectAsync();
+ await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
+
+ const long ClientTime = 1_700_000_000_123;
+ await SendAsync(socket, buffer => ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
+
+ var pong = ProtocolCodec.ReadPong(await ReceiveUntilAsync(socket, MessageType.ServerPong));
+
+ Assert.Equal(ClientTime, pong.ClientTimeMs);
+ }
+
+ [Fact]
+ public async Task VersionMismatch_IsRejected()
+ {
+ using var socket = await ConnectRawAsync();
+
+ await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
+ buffer,
+ new ClientHelloMessage((byte)(ProtocolConstants.Version + 1))));
+
+ var buffer = new byte[ProtocolConstants.MaxMessageSize];
+ var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
+
+ Assert.Equal(WebSocketMessageType.Close, result.MessageType);
+ Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
+ }
+
+ private static DateTime ToDate(ServerClockMessage clock) =>
+ DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime;
+
+ private async Task FindAsync(HttpClient client, int schoolId)
+ {
+ var state = await SchoolApiTests.GetSchoolsAsync(client);
+ var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
+
+ Assert.NotNull(school);
+ return school;
+ }
+
+ private async Task OpenSchoolAsync(int schoolId)
+ {
+ var socket = await ConnectAsync();
+ await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
+ await SendAsync(socket, buffer => ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
+ return socket;
+ }
+
+ private async Task ConnectAsync()
+ {
+ var socket = await ConnectRawAsync();
+ await SendAsync(socket, buffer =>
+ ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version)));
+ return socket;
+ }
+
+ private async Task ConnectRawAsync()
+ {
+ var http = fixture.App.GetEndpoint("server", "http");
+ var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
+
+ var socket = new ClientWebSocket();
+ await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
+ return socket;
+ }
+
+ private static async Task SendAsync(WebSocket socket, Func write)
+ {
+ var buffer = new byte[ProtocolCodec.MaxFrameSize];
+ var length = write(buffer);
+
+ await socket.SendAsync(
+ buffer.AsMemory(0, length),
+ WebSocketMessageType.Binary,
+ endOfMessage: true,
+ TestContext.Current.CancellationToken);
+ }
+
+ private static async Task ReceiveClockAsync(WebSocket socket) =>
+ ProtocolCodec.ReadClock(await ReceiveUntilAsync(socket, MessageType.ServerClock));
+
+ /// Drains clock frames until one satisfies .
+ private static async Task ReceiveClockWhereAsync(
+ WebSocket socket,
+ Func predicate)
+ {
+ for (var attempt = 0; attempt < 40; attempt++)
+ {
+ var clock = await ReceiveClockAsync(socket);
+ if (predicate(clock))
+ {
+ return clock;
+ }
+ }
+
+ throw new InvalidOperationException("No clock frame matched within 40 frames.");
+ }
+
+ /// Keeps reading clock frames for and returns the last one.
+ private static async Task ReceiveClockAfterAsync(WebSocket socket, TimeSpan duration)
+ {
+ var deadline = DateTime.UtcNow + duration;
+ var clock = await ReceiveClockAsync(socket);
+
+ while (DateTime.UtcNow < deadline)
+ {
+ clock = await ReceiveClockAsync(socket);
+ }
+
+ return clock;
+ }
+
+ /// Reads frames until one of shows up.
+ private static async Task ReceiveUntilAsync(WebSocket socket, MessageType expected, TimeSpan? timeout = null)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(timeout ?? DefaultTimeout);
+
+ var buffer = new byte[ProtocolConstants.MaxMessageSize];
+
+ while (true)
+ {
+ WebSocketReceiveResult result;
+ try
+ {
+ result = await socket.ReceiveAsync(buffer, cts.Token);
+ }
+ catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException($"No {expected} frame arrived within {timeout ?? DefaultTimeout}.");
+ }
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
+ }
+
+ var frame = buffer[..result.Count];
+ if (ProtocolCodec.PeekMessageType(frame) == expected)
+ {
+ return frame;
+ }
+ }
+ }
+}
diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
new file mode 100644
index 0000000..2104601
--- /dev/null
+++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
@@ -0,0 +1,201 @@
+using System.Net.Http.Json;
+
+namespace HSchool.AppHost.Tests;
+
+///
+/// The main menu's HTTP surface: list, create, delete. Tests share one AppHost, so each of them
+/// starts from an empty list rather than assuming one.
+///
+[Collection(AppHostCollection.Name)]
+public class SchoolApiTests(AppHostFixture fixture)
+{
+ private static readonly DateTime ExpectedDefaultStart = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ var state = await GetSchoolsAsync(client);
+
+ Assert.Equal(6, state.MaxSchools);
+ Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
+
+ // Without the "Z" the browser would read the start date in its own time zone and the
+ // creation form would offer the wrong hour.
+ Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
+ Assert.Equal(5d, state.GameMinutesPerRealSecond);
+ Assert.Empty(state.Schools);
+ }
+
+ [Fact]
+ public async Task CreateSchool_StartsAtTheRequestedDateAndRunning()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ var created = await CreateAsync(client, "Гимназия у моря", ExpectedDefaultStart);
+
+ Assert.Equal("Гимназия у моря", created.Name);
+ Assert.Equal(ExpectedDefaultStart, created.GameTime);
+
+ // Schools live from the moment they exist, whether or not anybody is inside.
+ Assert.True(created.Running);
+
+ var state = await GetSchoolsAsync(client);
+ Assert.Contains(state.Schools, school => school.Id == created.Id);
+ }
+
+ [Fact]
+ public async Task CreateSchool_BeyondTheLimit_IsRejected()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ var state = await GetSchoolsAsync(client);
+ for (var i = 0; i < state.MaxSchools; i++)
+ {
+ await CreateAsync(client, $"Школа {i + 1}", ExpectedDefaultStart);
+ }
+
+ using var response = await PostAsync(client, "Лишняя", ExpectedDefaultStart);
+
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
+ Assert.Equal("school-limit-reached", await ProblemCodeAsync(response));
+ Assert.Equal(state.MaxSchools, (await GetSchoolsAsync(client)).Schools.Count);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task CreateSchool_WithABlankName_IsRejected(string name)
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ using var response = await PostAsync(client, name, ExpectedDefaultStart);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal("invalid-name", await ProblemCodeAsync(response));
+ }
+
+ [Fact]
+ public async Task CreateSchool_WithAnImpossibleStartDate_IsRejected()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ using var response = await PostAsync(client, "Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal("invalid-start-date", await ProblemCodeAsync(response));
+ }
+
+ [Fact]
+ public async Task DeleteSchool_RemovesItAndFreesASlot()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+ var created = await CreateAsync(client, "На удаление", ExpectedDefaultStart);
+
+ using var response = await client.DeleteAsync($"/api/schools/{created.Id}", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.DoesNotContain((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
+ }
+
+ [Fact]
+ public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+
+ using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task RandomName_IsUsableAsIs()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ var suggestion = await client.GetFromJsonAsync(
+ "/api/schools/random-name",
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(suggestion);
+ Assert.False(string.IsNullOrWhiteSpace(suggestion.Name));
+
+ var created = await CreateAsync(client, suggestion.Name, ExpectedDefaultStart);
+ Assert.Equal(suggestion.Name, created.Name);
+ }
+
+ [Fact]
+ public async Task Status_ReportsTheLoopAndTheLimit()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+
+ var status = await client.GetFromJsonAsync("/api/status", TestContext.Current.CancellationToken);
+
+ Assert.NotNull(status);
+ Assert.Equal(20, status.TickRate);
+ Assert.Equal(6, status.MaxSchools);
+ Assert.True(status.Tick > 0, "The loop should have ticked by now.");
+ }
+
+ internal static async Task ResetAsync(HttpClient client)
+ {
+ var state = await GetSchoolsAsync(client);
+
+ foreach (var school in state.Schools)
+ {
+ using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+ }
+ }
+
+ internal static async Task GetSchoolsAsync(HttpClient client)
+ {
+ var state = await client.GetFromJsonAsync("/api/schools", TestContext.Current.CancellationToken);
+ Assert.NotNull(state);
+ return state;
+ }
+
+ internal static async Task CreateAsync(HttpClient client, string name, DateTime startDate)
+ {
+ using var response = await PostAsync(client, name, startDate);
+ response.EnsureSuccessStatusCode();
+
+ var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ return created;
+ }
+
+ private static Task PostAsync(HttpClient client, string name, DateTime startDate) =>
+ client.PostAsJsonAsync(
+ "/api/schools",
+ new { name, startDate },
+ TestContext.Current.CancellationToken);
+
+ private static async Task ProblemCodeAsync(HttpResponseMessage response)
+ {
+ var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ return problem?.Code;
+ }
+
+ internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
+
+ internal sealed record SchoolsResponse(
+ int MaxSchools,
+ DateTime DefaultStartDate,
+ double GameMinutesPerRealSecond,
+ IReadOnlyList Schools);
+
+ private sealed record RandomNameResponse(string Name);
+
+ private sealed record ProblemResponse(string? Code);
+
+ private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
+}
diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
index 2e11d3d..d2c5299 100644
--- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
+++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs
@@ -1,157 +1,189 @@
-namespace HSchool.Protocol.Tests;
-
-///
-/// The wire format is a contract with the browser client. Round-trips prove the C# side is
-/// self-consistent; the explicit byte-layout tests are what keeps
-/// src/HSchool.Client/src/net/protocol.ts honest.
-///
-public class ProtocolCodecTests
-{
- [Fact]
- public void Hello_RoundTrips()
- {
- var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
- Span buffer = stackalloc byte[64];
-
- var length = ProtocolCodec.WriteHello(buffer, message);
-
- Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
- }
-
- [Fact]
- public void Input_RoundTrips()
- {
- var message = new ClientInputMessage(0x01020304, InputButtons.Up | InputButtons.Right);
- Span buffer = stackalloc byte[16];
-
- var length = ProtocolCodec.WriteInput(buffer, message);
-
- Assert.Equal(6, length);
- Assert.Equal(message, ProtocolCodec.ReadInput(buffer[..length]));
- }
-
- [Fact]
- public void Ping_RoundTrips()
- {
- var message = new ClientPingMessage(1_700_000_000_123);
- Span buffer = stackalloc byte[16];
-
- var length = ProtocolCodec.WritePing(buffer, message);
-
- Assert.Equal(9, length);
- Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
- }
-
- [Fact]
- public void Welcome_RoundTripsAndIsFifteenBytes()
- {
- var message = new ServerWelcomeMessage(ProtocolConstants.Version, 42, 20, 1600f, 900f);
- Span buffer = stackalloc byte[32];
-
- var length = ProtocolCodec.WriteWelcome(buffer, message);
-
- Assert.Equal(15, length);
- Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
- }
-
- [Fact]
- public void Pong_RoundTrips()
- {
- var message = new ServerPongMessage(5, 99);
- Span buffer = stackalloc byte[32];
-
- var length = ProtocolCodec.WritePong(buffer, message);
-
- Assert.Equal(13, length);
- Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
- }
-
- [Fact]
- public void Snapshot_RoundTripsEveryEntityField()
- {
- ReadOnlySpan entities =
- [
- new EntitySnapshot(7, EntityKind.Player, 100f, 200f, 18f, 0x4CC9F0),
- new EntitySnapshot(8, EntityKind.Obstacle, 800f, 450f, 70f, 0x3A4553),
- ];
-
- var buffer = new byte[ProtocolCodec.SnapshotSize(entities.Length)];
- var length = ProtocolCodec.WriteSnapshot(buffer, 1234, entities);
-
- Assert.Equal(buffer.Length, length);
-
- var decoded = new EntitySnapshot[entities.Length];
- var count = ProtocolCodec.ReadSnapshot(buffer, decoded, out var tick);
-
- Assert.Equal(entities.Length, count);
- Assert.Equal(1234u, tick);
- Assert.Equal(entities[0], decoded[0]);
- Assert.Equal(entities[1], decoded[1]);
- }
-
- [Fact]
- public void SnapshotSize_MatchesTheLayoutTheClientAssumes()
- {
- // 1 type + 4 tick + 2 count, then 21 bytes per entity.
- Assert.Equal(7, ProtocolCodec.SnapshotSize(0));
- Assert.Equal(7 + 21, ProtocolCodec.SnapshotSize(1));
- Assert.Equal(21, ProtocolConstants.EntitySnapshotSize);
- }
-
- [Fact]
- public void Numbers_AreLittleEndian()
- {
- Span buffer = stackalloc byte[16];
- var length = ProtocolCodec.WriteInput(buffer, new ClientInputMessage(0x01020304, InputButtons.None));
-
- Assert.Equal((byte)MessageType.ClientInput, buffer[0]);
- Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
- Assert.Equal(6, length);
- }
-
- [Fact]
- public void PeekMessageType_ReadsTheFirstByte()
- {
- Span buffer = stackalloc byte[16];
- ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
-
- Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
- Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
- }
-
- [Fact]
- public void TruncatedFrame_Throws()
- {
- byte[] frame = [(byte)MessageType.ServerWelcome, ProtocolConstants.Version];
-
- Assert.Throws(() => ProtocolCodec.ReadWelcome(frame));
- }
-
- [Fact]
- public void WrongMessageId_Throws()
- {
- Span buffer = stackalloc byte[16];
- var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
- var frame = buffer[..length].ToArray();
-
- Assert.Throws(() => ProtocolCodec.ReadInput(frame));
- }
-
- [Fact]
- public void OversizedName_Throws()
- {
- var message = new ClientHelloMessage(ProtocolConstants.Version, new string('x', 100));
- var buffer = new byte[256];
-
- Assert.Throws(() => ProtocolCodec.WriteHello(buffer, message));
- }
-
- [Fact]
- public void UndersizedBuffer_Throws()
- {
- var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
- var buffer = new byte[4];
-
- Assert.Throws(() => ProtocolCodec.WriteWelcome(buffer, message));
- }
-}
+namespace HSchool.Protocol.Tests;
+
+///
+/// The wire format is a contract with the browser client. Round-trips prove the C# side is
+/// self-consistent; the explicit byte-layout tests are what keeps
+/// src/HSchool.Client/src/net/protocol.ts honest.
+///
+public class ProtocolCodecTests
+{
+ [Fact]
+ public void Hello_RoundTripsAndIsTwoBytes()
+ {
+ var message = new ClientHelloMessage(ProtocolConstants.Version);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteHello(buffer, message);
+
+ Assert.Equal(2, length);
+ Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
+ }
+
+ [Fact]
+ public void Ping_RoundTripsAndIsNineBytes()
+ {
+ var message = new ClientPingMessage(1_700_000_000_123);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WritePing(buffer, message);
+
+ Assert.Equal(9, length);
+ Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
+ }
+
+ [Fact]
+ public void OpenSchool_RoundTripsAndIsFiveBytes()
+ {
+ var message = new ClientOpenSchoolMessage(0x01020304);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteOpenSchool(buffer, message);
+
+ Assert.Equal(5, length);
+ Assert.Equal(message, ProtocolCodec.ReadOpenSchool(buffer[..length]));
+ }
+
+ [Fact]
+ public void CloseSchool_IsASingleByte()
+ {
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteCloseSchool(buffer);
+
+ Assert.Equal(1, length);
+ Assert.Equal(MessageType.ClientCloseSchool, ProtocolCodec.PeekMessageType(buffer[..length]));
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void SetRunning_RoundTripsAndIsTwoBytes(bool running)
+ {
+ var message = new ClientSetRunningMessage(running);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteSetRunning(buffer, message);
+
+ Assert.Equal(2, length);
+ Assert.Equal(message, ProtocolCodec.ReadSetRunning(buffer[..length]));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(4)]
+ public void SetSpeed_RoundTripsAndIsTwoBytes(byte speedIndex)
+ {
+ var message = new ClientSetSpeedMessage(speedIndex);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteSetSpeed(buffer, message);
+
+ Assert.Equal(2, length);
+ Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
+ }
+
+ [Fact]
+ public void Welcome_RoundTripsAndIsFourBytes()
+ {
+ var message = new ServerWelcomeMessage(ProtocolConstants.Version, 20, 6);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteWelcome(buffer, message);
+
+ Assert.Equal(4, length);
+ Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
+ }
+
+ [Fact]
+ public void Pong_RoundTripsAndIsThirteenBytes()
+ {
+ var message = new ServerPongMessage(5, 99);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WritePong(buffer, message);
+
+ Assert.Equal(13, length);
+ Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
+ }
+
+ [Fact]
+ public void Clock_RoundTripsAndIsFifteenBytes()
+ {
+ var message = new ServerClockMessage(7, 1_333_432_800_000, Running: true, SpeedIndex: 2);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteClock(buffer, message);
+
+ Assert.Equal(15, length);
+ Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
+ }
+
+ [Fact]
+ public void SchoolGone_RoundTripsAndIsFiveBytes()
+ {
+ var message = new ServerSchoolGoneMessage(3);
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+
+ var length = ProtocolCodec.WriteSchoolGone(buffer, message);
+
+ Assert.Equal(5, length);
+ Assert.Equal(message, ProtocolCodec.ReadSchoolGone(buffer[..length]));
+ }
+
+ [Fact]
+ public void Numbers_AreLittleEndian()
+ {
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+ ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(0x01020304));
+
+ Assert.Equal((byte)MessageType.ClientOpenSchool, buffer[0]);
+ Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
+ }
+
+ [Fact]
+ public void MaxFrameSize_FitsEveryMessage()
+ {
+ // The handlers size their buffers from this constant; the clock frame is the largest one.
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+ var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));
+
+ Assert.True(clock <= ProtocolCodec.MaxFrameSize);
+ }
+
+ [Fact]
+ public void PeekMessageType_ReadsTheFirstByte()
+ {
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+ ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
+
+ Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
+ Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
+ }
+
+ [Fact]
+ public void TruncatedFrame_Throws()
+ {
+ byte[] frame = [(byte)MessageType.ServerClock, 1, 2];
+
+ Assert.Throws(() => ProtocolCodec.ReadClock(frame));
+ }
+
+ [Fact]
+ public void WrongMessageId_Throws()
+ {
+ Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
+ var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
+ var frame = buffer[..length].ToArray();
+
+ Assert.Throws(() => ProtocolCodec.ReadOpenSchool(frame));
+ }
+
+ [Fact]
+ public void UndersizedBuffer_Throws()
+ {
+ var buffer = new byte[2];
+
+ Assert.Throws(() =>
+ ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, 0, false, 1)));
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/GameClockTests.cs b/tests/HSchool.Simulation.Tests/GameClockTests.cs
new file mode 100644
index 0000000..3afcec8
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/GameClockTests.cs
@@ -0,0 +1,128 @@
+namespace HSchool.Simulation.Tests;
+
+public class GameClockTests
+{
+ private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ private const double OneTwentiethOfASecond = 1d / 20d;
+ private const double GameMinutesPerRealSecond = 5d;
+
+ [Fact]
+ public void NewClock_StartsRunningAtTheStartDate()
+ {
+ var clock = new GameClock(Start);
+
+ Assert.Equal(Start, clock.Time);
+ Assert.True(clock.IsRunning);
+ Assert.Equal(ClockSpeed.DefaultIndex, clock.SpeedIndex);
+ Assert.Equal(1d, clock.Multiplier);
+ }
+
+ [Fact]
+ public void PausedClock_DoesNotMove()
+ {
+ var clock = new GameClock(Start) { IsRunning = false };
+
+ for (var i = 0; i < 100; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ Assert.Equal(Start, clock.Time);
+ }
+
+ [Fact]
+ public void OneRealSecond_AdvancesFiveGameMinutes()
+ {
+ var clock = new GameClock(Start);
+
+ // One real second at 20 Hz.
+ for (var i = 0; i < 20; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ Assert.Equal(Start.AddMinutes(5), clock.Time);
+ }
+
+ [Theory]
+ [InlineData(0, 2.5)]
+ [InlineData(1, 5)]
+ [InlineData(2, 10)]
+ [InlineData(3, 15)]
+ [InlineData(4, 20)]
+ public void SpeedIndex_ScalesTheGameMinutesPerSecond(int speedIndex, double expectedMinutes)
+ {
+ var clock = new GameClock(Start) { SpeedIndex = speedIndex };
+
+ for (var i = 0; i < 20; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ Assert.Equal(Start.AddMinutes(expectedMinutes), clock.Time);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(5)]
+ [InlineData(200)]
+ public void InvalidSpeedIndex_IsIgnored(int speedIndex)
+ {
+ var clock = new GameClock(Start) { SpeedIndex = 2 };
+
+ clock.SpeedIndex = speedIndex;
+
+ Assert.Equal(2, clock.SpeedIndex);
+ }
+
+ [Fact]
+ public void Pausing_FreezesTimeWhereItStopped()
+ {
+ var clock = new GameClock(Start);
+ for (var i = 0; i < 20; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ var paused = clock.Time;
+ clock.IsRunning = false;
+ for (var i = 0; i < 100; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ Assert.Equal(paused, clock.Time);
+ }
+
+ [Fact]
+ public void SameTickCount_AlwaysProducesTheSameDate()
+ {
+ Assert.Equal(RunOneHourOfTicks(), RunOneHourOfTicks());
+
+ static DateTime RunOneHourOfTicks()
+ {
+ var clock = new GameClock(Start) { SpeedIndex = 0 };
+ for (var i = 0; i < 20 * 60 * 60; i++)
+ {
+ clock.Advance(OneTwentiethOfASecond, GameMinutesPerRealSecond);
+ }
+
+ return clock.Time;
+ }
+ }
+
+ [Fact]
+ public void Time_IsUtcSoSerializationIsUnambiguous()
+ {
+ var clock = new GameClock(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Unspecified));
+
+ Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
+ }
+
+ [Fact]
+ public void StartDateOutsideTheSupportedRange_Throws()
+ {
+ Assert.Throws(() => new GameClock(new DateTime(1800, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/GameWorldTests.cs b/tests/HSchool.Simulation.Tests/GameWorldTests.cs
deleted file mode 100644
index c5eea8a..0000000
--- a/tests/HSchool.Simulation.Tests/GameWorldTests.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-using HSchool.Protocol;
-
-namespace HSchool.Simulation.Tests;
-
-public class GameWorldTests
-{
- private static SimulationOptions Options() => new()
- {
- TickRate = 20,
- WorldWidth = 1000f,
- WorldHeight = 1000f,
- PlayerSpeed = 200f,
- PlayerRadius = 10f,
- };
-
- private static EntitySnapshot Entity(GameWorld world, uint networkId)
- {
- var buffer = new List();
- world.CaptureSnapshot(buffer);
- return buffer.Single(entity => entity.Id == networkId);
- }
-
- [Fact]
- public void Tick_AdvancesTheTickCounter()
- {
- using var world = new GameWorld(Options());
-
- world.Tick();
- world.Tick();
-
- Assert.Equal(2u, world.CurrentTick);
- }
-
- [Fact]
- public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
- {
- using var world = new GameWorld(Options());
-
- var networkId = world.SpawnPlayer(playerId: 1);
-
- Assert.Equal(1, world.PlayerCount);
- Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
- }
-
- [Fact]
- public void SpawnPlayer_Twice_Throws()
- {
- using var world = new GameWorld(Options());
- world.SpawnPlayer(playerId: 1);
-
- Assert.Throws(() => world.SpawnPlayer(playerId: 1));
- }
-
- [Fact]
- public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
- {
- var options = Options();
- using var world = new GameWorld(options);
- var networkId = world.SpawnPlayer(playerId: 1);
- var startX = Entity(world, networkId).X;
-
- world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
- world.Tick();
-
- var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
- Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
- }
-
- [Fact]
- public void DiagonalInput_IsNotFasterThanCardinal()
- {
- var options = Options();
- using var world = new GameWorld(options);
-
- var straight = world.SpawnPlayer(playerId: 1);
- var diagonal = world.SpawnPlayer(playerId: 2);
-
- world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
- world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
- world.Tick();
-
- var straightBefore = Entity(world, straight);
- var diagonalBefore = Entity(world, diagonal);
- world.Tick();
-
- var straightStep = Distance(straightBefore, Entity(world, straight));
- var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
-
- Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
- }
-
- [Fact]
- public void Player_StopsAtTheWorldBounds()
- {
- var options = Options();
- using var world = new GameWorld(options);
- var networkId = world.SpawnPlayer(playerId: 1);
-
- world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
- for (var i = 0; i < 200; i++)
- {
- world.Tick();
- }
-
- Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
- }
-
- [Fact]
- public void StaleInput_IsIgnored()
- {
- using var world = new GameWorld(Options());
- var networkId = world.SpawnPlayer(playerId: 1);
- var startX = Entity(world, networkId).X;
-
- world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
- world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
- world.Tick();
-
- Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
- }
-
- [Fact]
- public void InputForAnUnknownPlayer_IsIgnored()
- {
- using var world = new GameWorld(Options());
-
- world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
- world.Tick();
-
- Assert.Equal(0, world.PlayerCount);
- }
-
- [Fact]
- public void DespawnPlayer_RemovesItFromSnapshots()
- {
- using var world = new GameWorld(Options());
- var networkId = world.SpawnPlayer(playerId: 1);
-
- world.DespawnPlayer(playerId: 1);
-
- var buffer = new List();
- world.CaptureSnapshot(buffer);
-
- Assert.Equal(0, world.PlayerCount);
- Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
- Assert.Null(world.GetNetworkId(playerId: 1));
- }
-
- [Fact]
- public void DespawnPlayer_Twice_IsHarmless()
- {
- using var world = new GameWorld(Options());
- world.SpawnPlayer(playerId: 1);
-
- world.DespawnPlayer(playerId: 1);
- world.DespawnPlayer(playerId: 1);
-
- Assert.Equal(0, world.PlayerCount);
- }
-
- [Fact]
- public void NetworkIds_AreNotRecycledAfterDespawn()
- {
- using var world = new GameWorld(Options());
- var first = world.SpawnPlayer(playerId: 1);
- world.DespawnPlayer(playerId: 1);
-
- var second = world.SpawnPlayer(playerId: 1);
-
- Assert.NotEqual(first, second);
- }
-
- [Fact]
- public void EmptyWorld_StillContainsTheStaticObstacles()
- {
- using var world = new GameWorld(Options());
-
- var buffer = new List();
- world.CaptureSnapshot(buffer);
-
- Assert.NotEmpty(buffer);
- Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
- }
-
- [Fact]
- public void Simulation_IsDeterministicForTheSameInputs()
- {
- var first = Run();
- var second = Run();
-
- Assert.Equal(first, second);
-
- static (float X, float Y) Run()
- {
- using var world = new GameWorld(Options());
- var networkId = world.SpawnPlayer(playerId: 3);
-
- for (var i = 0; i < 25; i++)
- {
- world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
- world.Tick();
- }
-
- var entity = Entity(world, networkId);
- return (entity.X, entity.Y);
- }
- }
-
- [Fact]
- public void UsingADisposedWorld_Throws()
- {
- var world = new GameWorld(Options());
- world.Dispose();
-
- Assert.Throws(world.Tick);
- }
-
- private static float Distance(EntitySnapshot from, EntitySnapshot to)
- {
- var dx = to.X - from.X;
- var dy = to.Y - from.Y;
- return MathF.Sqrt((dx * dx) + (dy * dy));
- }
-}
diff --git a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs
new file mode 100644
index 0000000..a88297f
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs
@@ -0,0 +1,179 @@
+namespace HSchool.Simulation.Tests;
+
+public class SchoolRegistryTests
+{
+ private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ private static SchoolRegistry NewRegistry(int maxSchools = 6) =>
+ new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 });
+
+ [Fact]
+ public void NewRegistry_IsEmpty()
+ {
+ using var registry = NewRegistry();
+
+ Assert.Equal(0, registry.Count);
+ Assert.Equal(6, registry.MaxSchools);
+ Assert.False(registry.IsFull);
+ }
+
+ [Fact]
+ public void Create_AddsASchoolAtTheGivenStartDate()
+ {
+ using var registry = NewRegistry();
+
+ var result = registry.Create("Гимназия №1", Start);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("Гимназия №1", result.School!.Name);
+ Assert.Equal(Start, result.School.Clock.Time);
+
+ // A new school starts living straight away; only the pause button stops it.
+ Assert.True(result.School.Clock.IsRunning);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public void Create_BeyondTheLimit_Fails()
+ {
+ using var registry = NewRegistry(maxSchools: 2);
+ registry.Create("Первая", Start);
+ registry.Create("Вторая", Start);
+
+ var result = registry.Create("Третья", Start);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(SchoolCreationError.LimitReached, result.Error);
+ Assert.True(registry.IsFull);
+ Assert.Equal(2, registry.Count);
+ }
+
+ [Fact]
+ public void Delete_FreesASlot()
+ {
+ using var registry = NewRegistry(maxSchools: 1);
+ var first = registry.Create("Первая", Start).School!;
+
+ Assert.False(registry.Create("Вторая", Start).Succeeded);
+ Assert.True(registry.Delete(first.Id));
+
+ Assert.True(registry.Create("Вторая", Start).Succeeded);
+ }
+
+ [Fact]
+ public void Delete_UnknownId_ReportsFailure()
+ {
+ using var registry = NewRegistry();
+
+ Assert.False(registry.Delete(42));
+ }
+
+ [Fact]
+ public void Ids_AreNotReusedAfterDeletion()
+ {
+ using var registry = NewRegistry();
+ var first = registry.Create("Первая", Start).School!;
+ registry.Delete(first.Id);
+
+ var second = registry.Create("Вторая", Start).School!;
+
+ Assert.NotEqual(first.Id, second.Id);
+ Assert.Null(registry.Find(first.Id));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t\n")]
+ public void Create_RejectsBlankNames(string name)
+ {
+ using var registry = NewRegistry();
+
+ Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error);
+ }
+
+ [Fact]
+ public void Create_RejectsOverlongNames()
+ {
+ using var registry = NewRegistry();
+
+ var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start);
+
+ Assert.Equal(SchoolCreationError.InvalidName, result.Error);
+ }
+
+ [Fact]
+ public void Create_TrimsAndStripsControlCharacters()
+ {
+ using var registry = NewRegistry();
+
+ var result = registry.Create(" Лицей ", Start);
+
+ Assert.Equal("Лицей", result.School!.Name);
+ }
+
+ [Fact]
+ public void Create_RejectsStartDatesOutsideTheSupportedRange()
+ {
+ using var registry = NewRegistry();
+
+ var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error);
+ }
+
+ [Fact]
+ public void Tick_AdvancesOnlyRunningSchools()
+ {
+ using var registry = NewRegistry();
+ var running = registry.Create("Идёт", Start).School!;
+ var paused = registry.Create("Стоит", Start).School!;
+ paused.Clock.IsRunning = false;
+
+ for (var i = 0; i < 20; i++)
+ {
+ registry.Tick();
+ }
+
+ Assert.Equal(Start.AddMinutes(5), running.Clock.Time);
+ Assert.Equal(Start, paused.Clock.Time);
+ }
+
+ [Fact]
+ public void SuggestName_NeverRepeatsAnExistingName()
+ {
+ using var registry = NewRegistry(maxSchools: 20);
+
+ for (var i = 0; i < 20; i++)
+ {
+ var suggestion = registry.SuggestName();
+ Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected.");
+ }
+
+ var names = registry.Schools.Select(school => school.Name).ToArray();
+ Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ }
+
+ [Fact]
+ public void SuggestedNames_FitTheNameLimit()
+ {
+ var generator = new SchoolNameGenerator(new Random(1234));
+
+ for (var i = 0; i < 200; i++)
+ {
+ var name = generator.Next([]);
+ Assert.InRange(name.Length, 1, School.MaxNameLength);
+ }
+ }
+
+ [Fact]
+ public void Dispose_DropsEverySchool()
+ {
+ var registry = NewRegistry();
+ registry.Create("Школа", Start);
+
+ registry.Dispose();
+
+ Assert.Equal(0, registry.Count);
+ }
+}