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