Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
+3 -3
View File
@@ -15,12 +15,12 @@ jobs:
with:
global-json-file: global.json
- run: dotnet restore HSchool.slnx
- run: dotnet restore h-school.sln
- run: dotnet build HSchool.slnx --no-restore --configuration Release
- run: dotnet build h-school.sln --no-restore --configuration Release
# The AppHost tests run headless, so no Node install is needed here.
- run: dotnet test HSchool.slnx --no-build --configuration Release
- run: dotnet test h-school.sln --no-build --configuration Release
client:
runs-on: ubuntu-latest
+43 -29
View File
@@ -8,16 +8,17 @@ way; this file is *how to work in them*.
| I want to change… | Go to |
| --- | --- |
| game rules, movement, entities | `src/HSchool.Simulation` |
| what the client receives | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, endpoints | `src/HSchool.Server` |
| schools, the game clock, game rules | `src/HSchool.Simulation` |
| the menu API (list, create, delete) | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, the loop | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| rendering, input, HUD | `src/HSchool.Client/src` |
| screens, dialogs, formatting | `src/HSchool.Client/src` |
## Commands
```bash
dotnet build HSchool.slnx
dotnet build h-school.sln
```
```bash
@@ -53,20 +54,22 @@ These are the rules that keep the base coherent. Breaking one is a design decisi
say so explicitly in the change description.
1. **The server is authoritative.** The client sends intents and draws what it is told. No game
logic in `src/HSchool.Client`.
logic in `src/HSchool.Client` — not even a local clock that ticks between frames.
2. **The protocol lives in three places at once.** `ProtocolCodec.cs`, `protocol.ts` and
`docs/protocol.md` change in the same commit. A layout change bumps
`ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets —
if one of them has to change, so do the other two.
3. **Only the loop thread touches `GameWorld`.** Everything inbound goes through
`GameCommandQueue`; everything outbound goes through the per-client outbox. No locks around the
ECS world, no `Task.Run` into it.
3. **Only the loop thread touches a `School` or the `SchoolRegistry`.** Everything inbound goes
through `GameCommandQueue`; menu reads use the immutable state the loop publishes; everything
outbound goes through the per-client outbox. No locks around the registry, no `Task.Run` into it.
4. **The simulation knows nothing about the network.** `HSchool.Simulation` must not reference
ASP.NET Core, sockets or logging infrastructure. It stays testable without a host.
5. **Fixed timestep.** Systems get `SimulationContext.DeltaTime`, never wall-clock time and never
`DateTime.Now`. Same inputs, same results — `Simulation_IsDeterministicForTheSameInputs` guards it.
6. **Everything from the wire is untrusted.** Validate lengths and ranges in the handler before
anything reaches the simulation.
5. **Fixed timestep.** The clock advances by `SimulationOptions.FixedDeltaTime`, never by
wall-clock deltas and never from `DateTime.Now`. Same tick count, same date.
6. **Everything from the wire is untrusted.** Validate lengths and ranges before anything reaches
the simulation — names, dates and speed indexes all arrive from a browser.
7. **One intent per message.** A frame that also resends a neighbouring field overwrites it with a
stale client copy; that is why running and speed are separate messages.
## Conventions
@@ -76,14 +79,16 @@ say so explicitly in the change description.
- Private fields are `_camelCase`; `.editorconfig` enforces it.
- Nullable is on everywhere. Don't add `!` to silence it; fix the flow.
- Internal by default in `HSchool.Server`; public only where another project consumes it.
- New tunables go on `SimulationOptions` with a default, not as a constant buried in a system.
- New tunables go on `SimulationOptions` with a default, not as a constant buried in a class.
**TypeScript**
- `strict` is on, no `any`, no non-null `!` assertions.
- Relative imports carry the `.ts` extension (bundler resolution is configured for it).
- Modules stay thin: `net/` speaks protocol, `game/` renders, `main.ts` wires them together.
- No framework. If a UI need appears, plain DOM first.
- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `main.ts`
wires them together.
- No framework, plain DOM. `ui/dom.ts` is the whole helper budget.
- UI strings are Russian; game dates are formatted through `format/gameTime.ts`, never inline.
**Both**
@@ -92,13 +97,15 @@ say so explicitly in the change description.
## Testing policy
- Simulation changes need a `GameWorld` test. They are fast, hermetic and do not need a host.
- Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
- Server wiring, endpoints and the WebSocket handshake belong in `tests/HSchool.AppHost.Tests`.
That suite shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting
per test costs about ten seconds each.
- Never assert on the *first* snapshot after a join without checking the entity is in it; the
frame in flight may predate the spawn. Use `ReceiveSnapshotWithAsync`.
- Server wiring, endpoints and the WebSocket belong in `tests/HSchool.AppHost.Tests`. That suite
shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs
about ten seconds each.
- Those tests also share one server, so schools survive between them: start each test by clearing
the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty.
- The screens have no unit tests — a DOM environment would cost a dependency the project does not
have. Verify UI changes by running the app.
## Dependencies
@@ -106,17 +113,24 @@ say so explicitly in the change description.
bare `<PackageReference Include="..." />` in the project.
- Transitive pinning is on, so a downgrade warning means you bump the central version rather than
adding a per-project override.
- Keep the dependency count low. Arch, PixiJS, Aspire and the test runners are the whole budget;
anything new needs a reason in the change description.
- Keep the dependency count low. Arch, Aspire and the test runners are the whole budget; `pixi.js`
is installed but not imported yet — it is there for the game view inside a school.
## Things that will bite you
- **`<dialog>`'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.
-14
View File
@@ -1,14 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/HSchool.AppHost/HSchool.AppHost.csproj" />
<Project Path="src/HSchool.Protocol/HSchool.Protocol.csproj" />
<Project Path="src/HSchool.Server/HSchool.Server.csproj" />
<Project Path="src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj" />
<Project Path="src/HSchool.Simulation/HSchool.Simulation.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/HSchool.AppHost.Tests/HSchool.AppHost.Tests.csproj" />
<Project Path="tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj" />
<Project Path="tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj" />
</Folder>
</Solution>
+35 -23
View File
@@ -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).
+65 -55
View File
@@ -1,17 +1,20 @@
# 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.
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 ─────────────────────────────┐
│ │
│ ┌────────────────────────┐ WebSocket /ws/game ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── binary ──────► │ HSchool.Client │ │
│ │ │ │ (Vite + Pixi) │ │
│ │ GameLoopService 20 Hz │ HTTP /api, /health └──────────────────┘
│ │ ├── GameCommandQueue│
│ │ ├── GameWorld (Arch)
│ ┌────────────────────────┐ 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 │
@@ -24,74 +27,81 @@ is no rendering on the server.
| 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.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 + PixiJS renderer. |
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
Dependency direction is one-way: `Protocol ← Simulation ← Server ← AppHost`. Nothing in
`Simulation` knows about HTTP, and nothing in `Protocol` knows about ECS.
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.** 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.
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.
`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
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.
## ECS layout
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.
Components are plain mutable structs in `HSchool.Simulation/Components`:
## Schools
- `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.
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.
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.
`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 player id.
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client 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.
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 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.
that cannot keep up loses intermediate clock frames instead of stalling the loop.
## 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.
- **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.
+123 -53
View File
@@ -1,7 +1,13 @@
# Wire protocol v1
# Wire protocol v3
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.**
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:
@@ -14,7 +20,49 @@ Three files must stay in sync — change them in the same commit:
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
## 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 19002999. |
| `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 `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
frame is obvious at a glance.
@@ -22,15 +70,19 @@ frame is obvious at a glance.
| Id | Direction | Message |
| --- | --- | --- |
| `0x01` | C → S | Hello |
| `0x02` | C → S | Input |
| `0x03` | C → S | Ping |
| `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 | Snapshot |
| `0x83` | S → C | Pong |
| `0x82` | S → C | Pong |
| `0x83` | S → C | Clock |
| `0x84` | S → C | SchoolGone |
## Client → server
### `0x01` Hello
### `0x01` Hello — 2 bytes
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
@@ -38,86 +90,104 @@ Must be the first frame; the server drops the connection if it does not arrive w
| --- | --- | --- |
| 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.
### `0x02` Ping — 9 bytes
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x02` |
| 1 | `u32` | sequence number, monotonically increasing |
| 5 | `u8` | button mask |
| 1 | `i64` | client clock in milliseconds |
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` OpenSchool — 5 bytes
### `0x03` Ping
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 | `i64` | client clock in milliseconds |
| 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 — 15 bytes
### `0x81` Welcome — 4 bytes
The first frame the client receives; no snapshot is queued before it.
The first frame the client receives.
| 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 |
| 2 | `u8` | tick rate in Hz |
| 3 | `u8` | maximum number of schools |
### `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:
### `0x82` Pong — 13 bytes
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x82` |
| 1 | `u32` | tick |
| 5 | `u16` | entity count |
| 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled |
Then, per entity (21 bytes):
### `0x83` Clock — 15 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
Sent every tick to every connection that has a school open, and only to those.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x83` |
| 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled |
| 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 64 KiB are refused with close status `1009 MessageTooBig`.
- 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.
- 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.
- 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 v1 yet
## Not in v3 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.
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.
+6 -8
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -7,13 +7,11 @@
<link rel="icon" href="data:," />
</head>
<body>
<div id="stage"></div>
<div id="hud">
<span data-hud="status">connecting</span>
<span data-hud="tick">tick 0</span>
<span data-hud="ping">-- ms</span>
<span data-hud="entities">0 entities</span>
</div>
<main id="app"></main>
<footer id="status">
<span data-status="connection">подключение</span>
<span data-status="ping">-- мс</span>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -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();
});
});
+72
View File
@@ -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 `<input type="date">` and `<input type="time">` 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;
}
-35
View File
@@ -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<string, HTMLElement>();
constructor(root: ParentNode = document) {
for (const element of root.querySelectorAll<HTMLElement>('[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;
}
}
}
-67
View File
@@ -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<Record<string, number>> = {
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<typeof setInterval> | 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;
}
}
}
-100
View File
@@ -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<number, Graphics>();
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<number>();
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();
}
}
@@ -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);
});
});
@@ -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;
}
+65 -50
View File
@@ -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 { 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 BACKGROUND_COLOR = 0x10141c;
const STATUS_LABELS: Record<ConnectionStatus, string> = {
connecting: 'подключение…',
connected: 'сервер на связи',
reconnecting: 'переподключение…',
closed: 'соединение закрыто',
};
async function bootstrap(): Promise<void> {
const app = new Application();
await app.init({
background: BACKGROUND_COLOR,
resizeTo: window,
antialias: true,
autoDensity: true,
resolution: window.devicePixelRatio,
/** Wires the two screens to one WebSocket connection. */
function bootstrap(): void {
const app = requireElement('#app');
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
const pingLabel = document.querySelector<HTMLElement>('[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),
});
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(), {
const connection = new GameConnection(gameSocketUrl(), {
onStatus: (status) => {
hud.setStatus(status);
if (status !== 'connected') {
snapshots.clear();
if (statusLabel !== null) {
statusLabel.textContent = STATUS_LABELS[status];
}
},
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);
onClock: (clock) => {
if (openSchool?.id === clock.schoolId) {
game.update(clock);
}
},
onSnapshot: (snapshot, receivedAt) => {
snapshots.push(snapshot, receivedAt);
hud.setTick(snapshot.tick);
hud.setEntityCount(snapshot.entities.length);
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)} мс`;
}
},
onLatency: (rttMs) => hud.setPing(rttMs),
});
const input = new InputTracker((buttons) => connection.sendInput(buttons));
function enterSchool(school: School): void {
openSchool = school;
menu.stop();
app.replaceChildren(game.element);
game.show(school);
connection.openSchool(school.id);
}
app.ticker.add(() => renderer.draw(snapshots.sample(performance.now())));
function leaveSchool(): void {
connection.closeSchool();
showMenu();
}
function showMenu(): void {
openSchool = null;
app.replaceChildren(menu.element);
menu.start();
}
connection.connect();
input.start();
showMenu();
window.addEventListener('beforeunload', () => {
input.stop();
connection.close();
});
window.addEventListener('beforeunload', () => 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;
function requireElement(selector: string): HTMLElement {
const element = document.querySelector<HTMLElement>(selector);
if (element === null) {
throw new Error(`${selector} is missing from index.html.`);
}
const generated = `player-${Math.floor(Math.random() * 10000)}`;
localStorage.setItem('hschool.playerName', generated);
return generated;
return element;
}
void bootstrap();
bootstrap();
+84
View File
@@ -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<SchoolsResponse> {
return request<SchoolsResponse>('/api/schools');
}
export async function fetchRandomName(): Promise<string> {
const response = await request<{ name: string }>('/api/schools/random-name');
return response.name;
}
export async function createSchool(name: string, startDate: Date): Promise<School> {
return request<School>('/api/schools', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, startDate: startDate.toISOString() }),
});
}
export async function deleteSchool(id: number): Promise<void> {
await request<void>(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false });
}
async function request<T>(
url: string,
init?: RequestInit,
options: { expectBody?: boolean } = {},
): Promise<T> {
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<ApiError> {
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);
}
}
+52 -20
View File
@@ -1,11 +1,14 @@
import {
decodeServerMessage,
encodeCloseSchool,
encodeHello,
encodeInput,
encodeOpenSchool,
encodePing,
encodeSetRunning,
encodeSetSpeed,
ProtocolError,
type ClockMessage,
type ServerMessage,
type SnapshotMessage,
type WelcomeMessage,
} from './protocol.ts';
@@ -14,7 +17,9 @@ export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'cl
export interface ConnectionHandlers {
onStatus?(status: ConnectionStatus): void;
onWelcome?(message: WelcomeMessage): void;
onSnapshot?(message: SnapshotMessage, receivedAt: number): 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;
}
@@ -24,20 +29,21 @@ 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.
* 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<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = RECONNECT_MIN_MS;
private inputSequence = 0;
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 playerName: string,
private readonly handlers: ConnectionHandlers = {},
) {}
@@ -51,9 +57,13 @@ export class GameConnection {
socket.addEventListener('open', () => {
this.reconnectDelay = RECONNECT_MIN_MS;
socket.send(encodeHello(this.playerName));
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));
@@ -61,14 +71,28 @@ export class GameConnection {
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) {
/** 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.inputSequence = (this.inputSequence + 1) >>> 0;
this.socket.send(encodeInput(this.inputSequence, buttons));
this.openSchoolId = null;
this.send(encodeCloseSchool());
}
setRunning(running: boolean): void {
this.send(encodeSetRunning(running));
}
setSpeed(speedIndex: number): void {
this.send(encodeSetSpeed(speedIndex));
}
close(): void {
@@ -79,6 +103,12 @@ export class GameConnection {
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;
@@ -103,8 +133,14 @@ export class GameConnection {
case 'welcome':
this.handlers.onWelcome?.(message);
break;
case 'snapshot':
this.handlers.onSnapshot?.(message, performance.now());
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));
@@ -127,11 +163,7 @@ export class GameConnection {
private startPinging(): void {
this.stopPinging();
this.pingTimer = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(encodePing(Date.now()));
}
}, PING_INTERVAL_MS);
this.pingTimer = setInterval(() => this.send(encodePing(Date.now())), PING_INTERVAL_MS);
}
private stopPinging(): void {
+76 -49
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest';
import {
CLOCK_SPEEDS,
decodeServerMessage,
encodeCloseSchool,
encodeHello,
encodeInput,
encodeOpenSchool,
encodePing,
EntityKind,
InputButtons,
encodeSetRunning,
encodeSetSpeed,
MessageType,
ProtocolError,
PROTOCOL_VERSION,
@@ -16,79 +18,89 @@ import {
* 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'));
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);
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.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(15);
const buffer = new ArrayBuffer(4);
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);
view.setUint8(2, 20);
view.setUint8(3, 6);
expect(decodeServerMessage(buffer)).toEqual({
type: 'welcome',
protocolVersion: PROTOCOL_VERSION,
playerEntityId: 42,
tickRate: 20,
worldWidth: 1600,
worldHeight: 900,
maxSchools: 6,
});
});
it('reads a snapshot with every entity field', () => {
const buffer = new ArrayBuffer(7 + 21);
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.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);
view.setUint8(0, MessageType.ServerClock);
view.setInt32(1, 7, true);
view.setBigInt64(5, BigInt(gameTimeMs), true);
view.setUint8(13, 1);
view.setUint8(14, 2);
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 }],
expect(decodeServerMessage(buffer)).toEqual({
type: 'clock',
schoolId: 7,
gameTime: new Date(gameTimeMs),
running: true,
speedIndex: 2,
});
});
@@ -102,6 +114,15 @@ describe('decodeServerMessage', () => {
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;
@@ -109,8 +130,14 @@ describe('decodeServerMessage', () => {
});
it('rejects a truncated frame', () => {
const buffer = new Uint8Array([MessageType.ServerWelcome, PROTOCOL_VERSION]).buffer;
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]);
});
});
+100 -93
View File
@@ -5,57 +5,34 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
export const PROTOCOL_VERSION = 1;
export const PROTOCOL_VERSION = 3;
export const MessageType = {
ClientHello: 0x01,
ClientInput: 0x02,
ClientPing: 0x03,
ClientPing: 0x02,
ClientOpenSchool: 0x03,
ClientCloseSchool: 0x04,
ClientSetRunning: 0x05,
ClientSetSpeed: 0x06,
ServerWelcome: 0x81,
ServerSnapshot: 0x82,
ServerPong: 0x83,
ServerPong: 0x82,
ServerClock: 0x83,
ServerSchoolGone: 0x84,
} as const;
export const InputButtons = {
None: 0,
Up: 1 << 0,
Down: 1 << 1,
Left: 1 << 2,
Right: 1 << 3,
} 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 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 const DEFAULT_SPEED_INDEX = 1;
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[];
readonly maxSchools: number;
}
export interface PongMessage {
@@ -64,33 +41,31 @@ export interface PongMessage {
readonly serverTick: number;
}
export type ServerMessage = WelcomeMessage | SnapshotMessage | PongMessage;
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 {}
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);
export function encodeHello(): ArrayBuffer {
const buffer = new ArrayBuffer(2);
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;
}
@@ -105,6 +80,47 @@ export function encodePing(clientTimeMs: number): ArrayBuffer {
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) {
@@ -112,59 +128,32 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
}
const view = new DataView(data);
const messageType = view.getUint8(0);
switch (messageType) {
switch (view.getUint8(0)) {
case MessageType.ServerWelcome:
return decodeWelcome(view);
case MessageType.ServerSnapshot:
return decodeSnapshot(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, 15);
ensure(view, 4);
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),
tickRate: view.getUint8(2),
maxSchools: view.getUint8(3),
};
}
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);
@@ -175,6 +164,24 @@ function decodePong(view: DataView): PongMessage {
};
}
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}.`);
+269 -13
View File
@@ -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;
}
@@ -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<boolean> {
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);
}
@@ -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<string>;
readonly create: (name: string, startDate: Date) => Promise<School>;
}
/**
* 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<School | null> {
const modal = new Modal<School | null>(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;
}
}
+49
View File
@@ -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<string, string>;
onClick?: (event: Event) => void;
}
export function el<K extends keyof HTMLElementTagNameMap>(
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();
}
+88
View File
@@ -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);
});
}
}
+192
View File
@@ -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<number, SchoolCard>();
private state: SchoolsResponse | null = null;
private refreshTimer: ReturnType<typeof setInterval> | 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<void> {
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<number>();
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<void> {
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<void> {
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 'школ';
}
+46
View File
@@ -0,0 +1,46 @@
import { el } from './dom.ts';
/**
* A `<dialog>` 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<T> {
readonly element = el('dialog', { class: 'dialog' });
private readonly result: Promise<T>;
private settle: (value: T) => void = () => {};
private settled = false;
constructor(private readonly dismissedValue: T) {
this.result = new Promise<T>((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<T> {
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);
}
}
+65
View File
@@ -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;
}
}
-9
View File
@@ -1,9 +0,0 @@
namespace HSchool.Protocol;
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
public enum EntityKind : byte
{
Unknown = 0,
Player = 1,
Obstacle = 2,
}
-12
View File
@@ -1,12 +0,0 @@
namespace HSchool.Protocol;
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
[Flags]
public enum InputButtons : byte
{
None = 0,
Up = 1 << 0,
Down = 1 << 1,
Left = 1 << 2,
Right = 1 << 3,
}
+8 -4
View File
@@ -9,10 +9,14 @@ public enum MessageType : byte
None = 0x00,
ClientHello = 0x01,
ClientInput = 0x02,
ClientPing = 0x03,
ClientPing = 0x02,
ClientOpenSchool = 0x03,
ClientCloseSchool = 0x04,
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ServerWelcome = 0x81,
ServerSnapshot = 0x82,
ServerPong = 0x83,
ServerPong = 0x82,
ServerClock = 0x83,
ServerSchoolGone = 0x84,
}
+29 -27
View File
@@ -1,37 +1,39 @@
namespace HSchool.Protocol;
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
/// <summary>
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
/// in future snapshots once client-side prediction lands.
/// </summary>
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
/// <summary>First frame from the client; carries nothing but the version handshake.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion);
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
public readonly record struct ClientPingMessage(long ClientTimeMs);
/// <summary>
/// Sent once per connection, before the first snapshot.
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
/// so the renderer can tell it apart from everyone else.
/// </summary>
public readonly record struct ServerWelcomeMessage(
byte ProtocolVersion,
uint PlayerEntityId,
byte TickRate,
float WorldWidth,
float WorldHeight);
/// <summary>Asks for clock updates of one school. Starts its calendar running.</summary>
public readonly record struct ClientOpenSchoolMessage(int SchoolId);
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
public readonly record struct EntitySnapshot(
uint Id,
EntityKind Kind,
float X,
float Y,
float Radius,
uint Color);
/// <summary>
/// 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.
/// </summary>
public readonly record struct ClientSetRunningMessage(bool Running);
/// <summary>Change the speed of the open school without touching whether it runs.</summary>
public readonly record struct ClientSetSpeedMessage(byte SpeedIndex);
/// <summary>Sent once per connection, before anything else.</summary>
public readonly record struct ServerWelcomeMessage(byte ProtocolVersion, byte TickRate, byte MaxSchools);
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
/// <summary>
/// State of the open school's calendar, sent every tick.
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
/// interpreted as UTC — the game calendar has no time zone.
/// </summary>
public readonly record struct ServerClockMessage(
int SchoolId,
long GameTimeUnixMs,
bool Running,
byte SpeedIndex);
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
+8 -26
View File
@@ -1,5 +1,4 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
@@ -23,14 +22,6 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
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));
@@ -39,6 +30,14 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
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));
@@ -47,23 +46,6 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
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)
+7 -33
View File
@@ -1,5 +1,4 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
@@ -14,8 +13,6 @@ public ref struct PacketWriter(Span<byte> buffer)
public readonly int Position => _position;
public readonly ReadOnlySpan<byte> Written => _buffer[.._position];
public void WriteByte(byte value)
{
EnsureRoom(sizeof(byte));
@@ -25,13 +22,6 @@ public ref struct PacketWriter(Span<byte> buffer)
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));
@@ -39,6 +29,13 @@ public ref struct PacketWriter(Span<byte> buffer)
_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));
@@ -46,29 +43,6 @@ public ref struct PacketWriter(Span<byte> buffer)
_position += sizeof(long);
}
public void WriteSingle(float value)
{
EnsureRoom(sizeof(float));
BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
_position += sizeof(float);
}
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
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)
+83 -73
View File
@@ -7,21 +7,14 @@ namespace HSchool.Protocol;
/// </summary>
public static class ProtocolCodec
{
/// <summary>Largest frame this codec produces; handlers can size their buffers from it.</summary>
public const int MaxFrameSize = 16;
public static int WriteHello(Span<byte> 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<byte> 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<byte> 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<byte> destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientCloseSchool);
return writer.Position;
}
public static int WriteSetRunning(Span<byte> 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<byte> 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<byte> 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;
}
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
public static int WriteClock(Span<byte> 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;
}
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
public static int SnapshotSize(int entityCount) =>
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
public static int WriteSchoolGone(Span<byte> 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<byte> 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<byte> 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<byte> source)
@@ -112,16 +112,35 @@ public static class ProtocolCodec
return new ClientPingMessage(reader.ReadInt64());
}
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientOpenSchool);
return new ClientOpenSchoolMessage(reader.ReadInt32());
}
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetRunning);
return new ClientSetRunningMessage(reader.ReadByte() != 0);
}
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetSpeed);
return new ClientSetSpeedMessage(reader.ReadByte());
}
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> 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<byte> source)
@@ -133,31 +152,22 @@ public static class ProtocolCodec
return new ServerPongMessage(clientTime, serverTick);
}
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
public static ServerClockMessage ReadClock(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSnapshot);
tick = reader.ReadUInt32();
var count = reader.ReadUInt16();
if (count > destination.Length)
{
throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
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);
}
for (var i = 0; i < count; i++)
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
{
destination[i] = new EntitySnapshot(
reader.ReadUInt32(),
(EntityKind)reader.ReadByte(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadUInt32());
}
return count;
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSchoolGone);
return new ServerSchoolGoneMessage(reader.ReadInt32());
}
private static void Expect(ref PacketReader reader, MessageType expected)
+2 -11
View File
@@ -4,17 +4,8 @@ namespace HSchool.Protocol;
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 1;
public const byte Version = 3;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 64 * 1024;
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
public const int MaxPlayerNameBytes = 32;
public const int MaxMessageSize = 8 * 1024;
}
+111
View File
@@ -0,0 +1,111 @@
using HSchool.Server.Game;
using HSchool.Simulation;
namespace HSchool.Server.Api;
/// <summary>
/// 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.
/// </summary>
internal static class SchoolEndpoints
{
/// <summary>How long a request waits for the loop thread before giving up.</summary>
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<string>());
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<SchoolCreationOutcome>());
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<bool>());
commands.Enqueue(command);
var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteSchool");
}
/// <summary>The loop thread must never be blocked by a continuation of a waiting request.</summary>
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
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);
}
/// <summary>Everything the main menu needs in one request.</summary>
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
IReadOnlyList<SchoolResponse> Schools);
internal sealed record RandomNameResponse(string Name);
+19 -9
View File
@@ -1,20 +1,30 @@
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a connection thread to the loop thread. The simulation is
/// single-threaded, so every mutation arrives as one of these.
/// 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.
/// </summary>
internal abstract record GameCommand
{
internal sealed record CreateSchool(
string Name,
DateTime StartDate,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
internal sealed record SuggestName(TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
/// <summary>
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
/// with the replication id so the handler can send a Welcome frame.
/// 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.
/// </summary>
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
internal sealed record Leave(uint PlayerId) : GameCommand;
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
}
+185 -51
View File
@@ -7,9 +7,9 @@ using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
/// drain commands, step the simulation, broadcast a full snapshot.
/// The world is touched from this thread only.
/// Owns every <see cref="School"/> 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.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
@@ -22,25 +22,28 @@ internal sealed class GameLoopService(
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly List<EntitySnapshot> _snapshotBuffer = [];
private readonly GameWorld _world = new(options.Value);
private readonly SchoolRegistry _schools = new(options.Value);
private uint _currentTick;
private int _playerCount;
private SchoolsState _publishedState = new(options.Value.MaxSchools, []);
public uint CurrentTick => Volatile.Read(ref _currentTick);
public int PlayerCount => Volatile.Read(ref _playerCount);
public SimulationOptions Options => _options;
/// <summary>
/// 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.
/// </summary>
public SchoolsState SchoolsState => Volatile.Read(ref _publishedState);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
"Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.",
_options.TickRate,
_options.WorldWidth,
_options.WorldHeight);
_options.MaxSchools,
_options.GameMinutesPerRealSecond);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
@@ -61,7 +64,8 @@ internal sealed class GameLoopService(
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
_world.Tick();
_schools.Tick();
Volatile.Write(ref _currentTick, _currentTick + 1);
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
@@ -76,8 +80,8 @@ internal sealed class GameLoopService(
if (steps > 0)
{
Volatile.Write(ref _currentTick, _world.CurrentTick);
BroadcastSnapshot();
PublishState();
BroadcastClocks();
}
}
}
@@ -87,8 +91,8 @@ internal sealed class GameLoopService(
}
finally
{
_world.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
_schools.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick);
}
}
@@ -98,56 +102,186 @@ internal sealed class GameLoopService(
{
switch (command)
{
case GameCommand.Join join:
HandleJoin(join);
case GameCommand.CreateSchool create:
HandleCreate(create);
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);
case GameCommand.DeleteSchool delete:
HandleDelete(delete);
break;
case GameCommand.Input input:
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
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 HandleJoin(GameCommand.Join join)
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);
}
/// <summary>
/// The connection stops receiving clock frames for that school. The calendar keeps running —
/// schools live whether or not somebody is looking at them.
/// </summary>
private void StopWatching(uint playerId, int schoolId)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId == schoolId)
{
client.OpenSchoolId = null;
}
}
/// <summary>Applies a change to the school a connection has open, if it still has one.</summary>
private void WithOpenSchool(uint playerId, Action<School> 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);
/// <summary>Runs work for a waiting request thread without letting an exception kill the loop.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
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);
completion.TrySetResult(work());
}
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);
completion.TrySetException(ex);
}
}
}
+8 -7
View File
@@ -10,16 +10,17 @@ internal sealed class GameMetrics : IDisposable
private readonly Meter _meter;
private readonly Counter<long> _ticks;
private readonly Histogram<double> _tickDuration;
private readonly UpDownCounter<long> _connectedPlayers;
private readonly Counter<long> _snapshotBytes;
private readonly UpDownCounter<long> _connections;
private int _schools;
public GameMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
_connections = _meter.CreateUpDownCounter<long>("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();
}
@@ -0,0 +1,9 @@
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>What the loop thread reports back after trying to create a school.</summary>
internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
}
+10
View File
@@ -0,0 +1,10 @@
namespace HSchool.Server.Game;
/// <summary>
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
/// never leaves the loop thread.
/// </summary>
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
/// <summary>Everything the main menu needs in one read.</summary>
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
+1
View File
@@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
+6 -20
View File
@@ -3,7 +3,7 @@ using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out player ids.</summary>
/// <summary>Tracks live connections and hands out client ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
@@ -11,6 +11,9 @@ internal sealed class ClientRegistry
public int Count => _clients.Count;
/// <summary>Snapshot-free enumeration; safe because the dictionary is concurrent.</summary>
public IEnumerable<GameClient> All => _clients.Values;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
@@ -19,24 +22,7 @@ internal sealed class ClientRegistry
return client;
}
public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
/// <summary>
/// Queues the same frame for every client that finished its handshake; the buffer must not
/// be reused afterwards.
/// </summary>
public int Broadcast(ReadOnlyMemory<byte> frame)
{
var recipients = 0;
foreach (var client in _clients.Values)
{
if (client.IsReady && client.TrySend(frame))
{
recipients++;
}
}
return recipients;
}
}
+21 -7
View File
@@ -4,9 +4,9 @@ using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// 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.
/// </summary>
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}";
/// <summary>
/// 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.
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the loop thread,
/// read by the connection thread on disconnect.
/// </summary>
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);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
+40 -58
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
GameMetrics metrics,
ILogger<GameSocketHandler> 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<uint>(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<byte>.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(
}
}
}
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
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];
}
}
+10 -19
View File
@@ -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<SimulationOptions>()
.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<GameCommandQueue>();
@@ -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();
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(
uint Tick,
int TickRate,
int Players,
int Connections,
float WorldWidth,
float WorldHeight);
/// <summary>Loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
+3 -4
View File
@@ -8,9 +8,8 @@
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"WorldWidth": 1600,
"WorldHeight": 900,
"PlayerSpeed": 260,
"PlayerRadius": 18
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00"
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace HSchool.Simulation;
/// <summary>
/// The speed buttons the player can pick, as an index on the wire. The table is duplicated in
/// <c>src/HSchool.Client/src/net/protocol.ts</c> — indexes, not multipliers, travel over the socket.
/// </summary>
public static class ClockSpeed
{
/// <summary>×½, ×1, ×2, ×3, ×4.</summary>
public static ReadOnlySpan<double> Multipliers => [0.5d, 1d, 2d, 3d, 4d];
/// <summary>Index of ×1, the speed a school starts at.</summary>
public const int DefaultIndex = 1;
public static int Count => Multipliers.Length;
public static bool IsValid(int index) => index >= 0 && index < Multipliers.Length;
/// <summary>Multiplier for a validated index; out-of-range values fall back to ×1.</summary>
public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex];
}
@@ -1,12 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>
/// Stable replication id. Arch entity ids are recycled, so the client gets this
/// monotonically increasing value instead.
/// </summary>
public struct NetworkId
{
public uint Value;
public NetworkId(uint value) => Value = value;
}
@@ -1,19 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Marks an entity as driven by a connected client's input.</summary>
public struct PlayerControl
{
/// <summary>Network id of the owning connection.</summary>
public uint PlayerId;
/// <summary>Latest intent received from that connection.</summary>
public InputButtons Buttons;
/// <summary>Sequence number of that intent; reserved for prediction/reconciliation.</summary>
public uint LastInputSequence;
/// <summary>Movement speed in units per second.</summary>
public float Speed;
}
@@ -1,14 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>World-space position in simulation units.</summary>
public struct Position
{
public float X;
public float Y;
public Position(float x, float y)
{
X = x;
Y = y;
}
}
@@ -1,13 +0,0 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Everything the client needs to draw the entity; replicated verbatim in snapshots.</summary>
public struct Renderable
{
public EntityKind Kind;
public float Radius;
/// <summary>Packed 0x00RRGGBB.</summary>
public uint Color;
}
@@ -1,14 +0,0 @@
namespace HSchool.Simulation.Components;
/// <summary>Simulation units per second, integrated by <c>MovementSystem</c>.</summary>
public struct Velocity
{
public float X;
public float Y;
public Velocity(float x, float y)
{
X = x;
Y = y;
}
}
+67
View File
@@ -0,0 +1,67 @@
namespace HSchool.Simulation;
/// <summary>
/// In-game calendar of one school. Time only moves while <see cref="IsRunning"/> is set, and it
/// moves by whole fixed steps — never by wall-clock deltas — so the same tick count always
/// produces the same date.
/// </summary>
public sealed class GameClock
{
/// <summary>Earliest date a school may start at; anything below is a typo, not a design choice.</summary>
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; }
/// <summary>
/// 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.
/// </summary>
public bool IsRunning { get; set; } = true;
/// <summary>Index into <see cref="ClockSpeed.Multipliers"/>; invalid values are ignored.</summary>
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;
/// <summary>
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
/// base rate and the current speed. Does nothing while paused.
/// </summary>
public void Advance(double realSeconds, double gameMinutesPerRealSecond)
{
if (!IsRunning)
{
return;
}
var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
Time = Time.AddMinutes(gameMinutes);
}
}
-199
View File
@@ -1,199 +0,0 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
using HSchool.Simulation.Systems;
namespace HSchool.Simulation;
/// <summary>
/// The authoritative world: an Arch <see cref="World"/> 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.
/// </summary>
public sealed class GameWorld : IDisposable
{
private readonly World _world;
private readonly ISimulationSystem[] _systems;
private readonly Dictionary<uint, Entity> _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; }
/// <summary>Number of fixed steps simulated so far.</summary>
public uint CurrentTick { get; private set; }
public int PlayerCount => _playerEntities.Count;
public int EntityCount => _world.CountEntities(new QueryDescription().WithAll<NetworkId>());
/// <summary>Adds a player body. Returns its replication id.</summary>
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);
}
}
/// <summary>Stores the latest intent for a player; applied on the next tick.</summary>
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<PlayerControl>(entity);
// Late/duplicate packets carry a stale sequence; the newest intent wins.
if (sequence < control.LastInputSequence)
{
return;
}
control.Buttons = buttons;
control.LastInputSequence = sequence;
}
/// <summary>Runs one fixed step of the pipeline.</summary>
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);
}
}
/// <summary>Fills <paramref name="buffer"/> with the replicated state of every visible entity.</summary>
public void CaptureSnapshot(List<EntitySnapshot> buffer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(buffer);
buffer.Clear();
var query = new QueryDescription().WithAll<NetworkId, Position, Renderable>();
_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));
});
}
/// <summary>Replication id of a connected player, or <c>null</c> if it is not spawned.</summary>
public uint? GetNetworkId(uint playerId) =>
_playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
? _world.Get<NetworkId>(entity).Value
: null;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
World.Destroy(_world);
}
/// <summary>A few static blocks so an empty world still shows something on screen.</summary>
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,
});
}
}
/// <summary>Deterministic spread of spawn points around the centre of the field.</summary>
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));
}
}
@@ -9,8 +9,4 @@
<PackageReference Include="Arch" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
</Project>
@@ -1,12 +0,0 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One stage of the fixed-step pipeline. Systems run in registration order on the
/// loop thread and must not capture per-step state.
/// </summary>
public interface ISimulationSystem
{
void Update(World world, in SimulationContext context);
}
-22
View File
@@ -1,22 +0,0 @@
namespace HSchool.Simulation;
/// <summary>Stable colours for replicated entities, packed as 0x00RRGGBB.</summary>
public static class Palette
{
public const uint Obstacle = 0x3A4553;
private static readonly uint[] PlayerColors =
[
0x4CC9F0,
0xF72585,
0x7BF1A8,
0xFFB703,
0xB388EB,
0xFF7A5C,
0x5CE1E6,
0xE9FF70,
];
/// <summary>Same player id always gets the same colour, on both server and client.</summary>
public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
}
+54
View File
@@ -0,0 +1,54 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// 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.
/// </summary>
public sealed class School : IDisposable
{
/// <summary>Longest name a school may carry, in characters.</summary>
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; }
/// <summary>The Arch world backing this school. Only the loop thread may touch it.</summary>
public World World { get; }
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary>
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);
}
}
@@ -0,0 +1,57 @@
namespace HSchool.Simulation;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// A name that is not in <paramref name="taken"/>. Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted.
/// </summary>
public string Next(IEnumerable<string> taken)
{
var used = new HashSet<string>(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)]}»";
}
}
+141
View File
@@ -0,0 +1,141 @@
namespace HSchool.Simulation;
/// <summary>Why a school could not be created.</summary>
public enum SchoolCreationError
{
None = 0,
LimitReached,
InvalidName,
InvalidStartDate,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public sealed class SchoolRegistry : IDisposable
{
private readonly SimulationOptions _options;
private readonly List<School> _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;
/// <summary>Schools in creation order — the order the menu lists them in.</summary>
public IReadOnlyList<School> 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;
}
/// <summary>Advances every running school by one fixed step.</summary>
public void Tick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
foreach (var school in _schools)
{
school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
}
}
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name));
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
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();
}
}
@@ -1,7 +0,0 @@
namespace HSchool.Simulation;
/// <summary>Per-step data handed to every system.</summary>
/// <param name="Tick">Index of the step being simulated.</param>
/// <param name="DeltaTime">Fixed step length in seconds.</param>
/// <param name="Options">Simulation tunables.</param>
public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
+15 -5
View File
@@ -5,19 +5,29 @@ public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;
public float WorldWidth { get; set; } = 1600f;
/// <summary>How many schools may exist at the same time.</summary>
public int MaxSchools { get; set; } = 6;
public float WorldHeight { get; set; } = 900f;
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
public double GameMinutesPerRealSecond { get; set; } = 5d;
public float PlayerSpeed { get; set; } = 260f;
/// <summary>Prefilled start of a new school; the client shows it in the creation form.</summary>
public DateTime DefaultStartDate
{
get => _defaultStartDate;
public float PlayerRadius { get; set; } = 18f;
// 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);
}
/// <summary>Length of one fixed step.</summary>
public float FixedDeltaTime => 1f / TickRate;
public double FixedDeltaTime => 1d / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
}
@@ -1,22 +0,0 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Integrates velocity into position with the fixed step.</summary>
public sealed class MovementSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity>();
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;
});
}
}
@@ -1,37 +0,0 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Turns the latest button mask of every player into a velocity vector.</summary>
public sealed class PlayerInputSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<PlayerControl, Velocity>();
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;
});
}
}
@@ -1,47 +0,0 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Keeps every body inside the play field and kills the velocity it pushed with.</summary>
public sealed class WorldBoundsSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity, Renderable>();
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;
}
});
}
}
@@ -1,243 +0,0 @@
using System.Net.WebSockets;
using System.Text.Json;
using HSchool.Protocol;
namespace HSchool.AppHost.Tests;
/// <summary>
/// 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.
/// </summary>
[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<StatusResponse>(
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<ClientWebSocket> 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<ClientWebSocket> 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<byte[], int> 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<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
private static async Task<EntitySnapshot[]> 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];
}
/// <summary>
/// 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.
/// </summary>
private static async Task<EntitySnapshot[]> 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.");
}
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
private static async Task<byte[]> 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<StatusResponse> GetStatusAsync(HttpClient client)
{
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
}
private static async Task WaitUntilAsync(Func<Task<bool>> 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);
}
@@ -0,0 +1,357 @@
using System.Net.WebSockets;
using HSchool.Protocol;
namespace HSchool.AppHost.Tests;
/// <summary>
/// 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.
/// </summary>
[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<TimeoutException>(() =>
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<SchoolApiTests.SchoolResponse> 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<ClientWebSocket> 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<ClientWebSocket> ConnectAsync()
{
var socket = await ConnectRawAsync();
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version)));
return socket;
}
private async Task<ClientWebSocket> 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<byte[], int> 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<ServerClockMessage> ReceiveClockAsync(WebSocket socket) =>
ProtocolCodec.ReadClock(await ReceiveUntilAsync(socket, MessageType.ServerClock));
/// <summary>Drains clock frames until one satisfies <paramref name="predicate"/>.</summary>
private static async Task<ServerClockMessage> ReceiveClockWhereAsync(
WebSocket socket,
Func<ServerClockMessage, bool> 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.");
}
/// <summary>Keeps reading clock frames for <paramref name="duration"/> and returns the last one.</summary>
private static async Task<ServerClockMessage> 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;
}
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
private static async Task<byte[]> 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;
}
}
}
}
@@ -0,0 +1,201 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
/// <summary>
/// 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.
/// </summary>
[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<RandomNameResponse>(
"/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<StatusResponse>("/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<SchoolsResponse> GetSchoolsAsync(HttpClient client)
{
var state = await client.GetFromJsonAsync<SchoolsResponse>("/api/schools", TestContext.Current.CancellationToken);
Assert.NotNull(state);
return state;
}
internal static async Task<SchoolResponse> CreateAsync(HttpClient client, string name, DateTime startDate)
{
using var response = await PostAsync(client, name, startDate);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
client.PostAsJsonAsync(
"/api/schools",
new { name, startDate },
TestContext.Current.CancellationToken);
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(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<SchoolResponse> 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);
}
@@ -8,33 +8,22 @@ namespace HSchool.Protocol.Tests;
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTrips()
public void Hello_RoundTripsAndIsTwoBytes()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
Span<byte> buffer = stackalloc byte[64];
var message = new ClientHelloMessage(ProtocolConstants.Version);
Span<byte> 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 Input_RoundTrips()
{
var message = new ClientInputMessage(0x01020304, InputButtons.Up | InputButtons.Right);
Span<byte> 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()
public void Ping_RoundTripsAndIsNineBytes()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span<byte> buffer = stackalloc byte[16];
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, message);
@@ -43,22 +32,73 @@ public class ProtocolCodecTests
}
[Fact]
public void Welcome_RoundTripsAndIsFifteenBytes()
public void OpenSchool_RoundTripsAndIsFiveBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 42, 20, 1600f, 900f);
Span<byte> buffer = stackalloc byte[32];
var message = new ClientOpenSchoolMessage(0x01020304);
Span<byte> 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<byte> 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<byte> 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<byte> 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<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(15, length);
Assert.Equal(4, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTrips()
public void Pong_RoundTripsAndIsThirteenBytes()
{
var message = new ServerPongMessage(5, 99);
Span<byte> buffer = stackalloc byte[32];
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(buffer, message);
@@ -67,52 +107,53 @@ public class ProtocolCodecTests
}
[Fact]
public void Snapshot_RoundTripsEveryEntityField()
public void Clock_RoundTripsAndIsFifteenBytes()
{
ReadOnlySpan<EntitySnapshot> entities =
[
new EntitySnapshot(7, EntityKind.Player, 100f, 200f, 18f, 0x4CC9F0),
new EntitySnapshot(8, EntityKind.Obstacle, 800f, 450f, 70f, 0x3A4553),
];
var message = new ServerClockMessage(7, 1_333_432_800_000, Running: true, SpeedIndex: 2);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var buffer = new byte[ProtocolCodec.SnapshotSize(entities.Length)];
var length = ProtocolCodec.WriteSnapshot(buffer, 1234, entities);
var length = ProtocolCodec.WriteClock(buffer, message);
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]);
Assert.Equal(15, length);
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
}
[Fact]
public void SnapshotSize_MatchesTheLayoutTheClientAssumes()
public void SchoolGone_RoundTripsAndIsFiveBytes()
{
// 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);
var message = new ServerSchoolGoneMessage(3);
Span<byte> 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<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, new ClientInputMessage(0x01020304, InputButtons.None));
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(0x01020304));
Assert.Equal((byte)MessageType.ClientInput, buffer[0]);
Assert.Equal((byte)MessageType.ClientOpenSchool, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(6, length);
}
[Fact]
public void MaxFrameSize_FitsEveryMessage()
{
// The handlers size their buffers from this constant; the clock frame is the largest one.
Span<byte> 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<byte> buffer = stackalloc byte[16];
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
@@ -122,36 +163,27 @@ public class ProtocolCodecTests
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerWelcome, ProtocolConstants.Version];
byte[] frame = [(byte)MessageType.ServerClock, 1, 2];
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadWelcome(frame));
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadClock(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[16];
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => 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<ProtocolException>(() => ProtocolCodec.WriteHello(buffer, message));
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadOpenSchool(frame));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
var buffer = new byte[4];
var buffer = new byte[2];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteWelcome(buffer, message));
Assert.Throws<ProtocolException>(() =>
ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, 0, false, 1)));
}
}
@@ -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<ArgumentOutOfRangeException>(() => new GameClock(new DateTime(1800, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
}
}
@@ -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<EntitySnapshot>();
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<InvalidOperationException>(() => 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<EntitySnapshot>();
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<EntitySnapshot>();
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<ObjectDisposedException>(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));
}
}
@@ -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);
}
}