diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f0399c8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,42 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{cs,csx}] +indent_size = 4 + +# Namespaces and usings +csharp_style_namespace_declarations = file_scoped:warning +dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace:warning + +# Expression preferences +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_prefer_braces = true:warning +csharp_style_prefer_primary_constructors = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion + +# Naming: private fields are _camelCase +dotnet_naming_rule.private_fields_underscore.severity = warning +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_prefix +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_prefix.required_prefix = _ +dotnet_naming_style.underscore_prefix.capitalization = camel_case + +[*.{ts,js,mts,cts,json,jsonc,css,html,yml,yaml}] +indent_size = 2 + +[*.{csproj,props,targets,slnx}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0974a8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - run: dotnet restore HSchool.slnx + + - run: dotnet build HSchool.slnx --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 + + client: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/HSchool.Client + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/HSchool.Client/package-lock.json + + - run: npm ci + + - run: npm run build + + - run: npm test diff --git a/.gitignore b/.gitignore index 7e2e97c..501be1e 100644 --- a/.gitignore +++ b/.gitignore @@ -414,3 +414,7 @@ FodyWeavers.xsd # Built Visual Studio Code Extensions *.vsix + +# h-school +src/HSchool.Client/dist/ +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3689b35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# Working agreements + +Read this before changing anything. It is written for coding agents, and it is just as valid for +humans. [`docs/architecture.md`](docs/architecture.md) explains *why* the pieces are shaped this +way; this file is *how to work in them*. + +## Orientation + +| 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` | +| what runs locally | `src/HSchool.AppHost/AppHost.cs` | +| rendering, input, HUD | `src/HSchool.Client/src` | + +## Commands + +```bash +dotnet build HSchool.slnx +``` + +```bash +dotnet test +``` + +```bash +npm --prefix src/HSchool.Client test +``` + +```bash +npm --prefix src/HSchool.Client run build +``` + +```bash +dotnet run --project src/HSchool.AppHost +``` + +`run-aspire.cmd` is the same command for Windows users who want a double-clickable entry point — +keep the two in sync if the AppHost path ever moves. + +`dotnet run --project src/HSchool.AppHost` starts the server *and* the Vite dev server and opens +the Aspire dashboard. The Vite port is assigned per run (`npm run dev -- --port `), so read +the client URL off the dashboard instead of assuming 5173. + +Do not start a dev server with a bare `npm run dev` when you meant to run the whole app — the +client only finds the backend through the Aspire-injected `SERVER_HTTP` environment variable, or +the `localhost:5180` fallback that matches the server's own launch profile. + +## Invariants + +These are the rules that keep the base coherent. Breaking one is a design decision, not a detail — +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`. +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. +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. + +## Conventions + +**C#** + +- File-scoped namespaces, `var` where the type is obvious, primary constructors for services. +- 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. + +**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. + +**Both** + +- Comments explain *why*, not *what*. Assume the reader can read code. +- Match the surrounding style rather than introducing a new one. + +## Testing policy + +- Simulation changes need a `GameWorld` test. They are fast, hermetic and do not need a 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`. + +## Dependencies + +- NuGet versions are centrally managed in `Directory.Packages.props`. Add the version there and a + 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. + +## Things that will bite you + +- `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. +- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties + are used throughout. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d28a1ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# h-school + +Working agreements, commands and invariants live in @AGENTS.md — read it before changing code. + +Architecture: @docs/architecture.md +Wire protocol: @docs/protocol.md diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ae3a5e0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + net10.0 + latest + enable + enable + true + true + false + true + true + + + + + false + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..d96958c --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HSchool.slnx b/HSchool.slnx new file mode 100644 index 0000000..4e3a243 --- /dev/null +++ b/HSchool.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/README.md b/README.md index b7243f4..171cf09 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ -# h-school - +# 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. + +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. + +## Stack + +| Layer | Choice | +| --- | --- | +| 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 | +| Orchestration | .NET Aspire 13 | +| Tests | xUnit v3, Vitest, `Aspire.Hosting.Testing` | + +## Prerequisites + +- [.NET SDK 10](https://dotnet.microsoft.com/download) (`global.json` pins the 10.0.1xx band) +- [Node.js](https://nodejs.org/) 22.12 or newer +- Optional: the Aspire CLI (`dotnet tool install -g aspire.cli`) if you prefer `aspire run` + +## Run everything + +```bash +dotnet run --project src/HSchool.AppHost +``` + +On Windows `run-aspire.cmd` does the same and can be double-clicked; it checks that the .NET SDK +and Node are on PATH first and passes any arguments through +(`run-aspire.cmd --launch-profile http`). + +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. + +Open the same URL in a second tab to see another player: both are simulated by the one server. + +## Run the pieces separately + +```bash +dotnet run --project src/HSchool.Server +``` + +```bash +npm --prefix src/HSchool.Client run dev +``` + +Without Aspire the client falls back to `http://localhost:5180` for its `/api` and `/ws` proxy, +which matches the server's launch profile. + +## Tests + +```bash +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. + +```bash +npm --prefix src/HSchool.Client test +``` + +Vitest covers the client codec and snapshot interpolation. + +## 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.ServiceDefaults/ Aspire telemetry, health checks, resilience + HSchool.AppHost/ Aspire orchestration + HSchool.Client/ Vite + TypeScript + PixiJS renderer +tests/ +docs/ + architecture.md how the pieces fit together + protocol.md the wire format, byte by byte +AGENTS.md working agreements for humans and coding agents +``` + +## Configuration + +Simulation tunables live under the `Simulation` section of +`src/HSchool.Server/appsettings.json`: + +| 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 | + +## 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 +[`docs/architecture.md`](docs/architecture.md). diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..c2abe47 --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/HSchool.AppHost/HSchool.AppHost.csproj" + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8d6c338 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,97 @@ +# 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. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..f3e52b8 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,123 @@ +# 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. diff --git a/global.json b/global.json new file mode 100644 index 0000000..b24aad6 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestPatch" + } +} diff --git a/h-school.sln b/h-school.sln new file mode 100644 index 0000000..47b8d20 --- /dev/null +++ b/h-school.sln @@ -0,0 +1,80 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.AppHost", "src\HSchool.AppHost\HSchool.AppHost.csproj", "{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol", "src\HSchool.Protocol\HSchool.Protocol.csproj", "{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Server", "src\HSchool.Server\HSchool.Server.csproj", "{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.ServiceDefaults", "src\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj", "{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation", "src\HSchool.Simulation\HSchool.Simulation.csproj", "{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.AppHost.Tests", "tests\HSchool.AppHost.Tests\HSchool.AppHost.Tests.csproj", "{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol.Tests", "tests\HSchool.Protocol.Tests\HSchool.Protocol.Tests.csproj", "{962E7F03-8B12-5802-91AA-105EEC2060E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation.Tests", "tests\HSchool.Simulation.Tests\HSchool.Simulation.Tests.csproj", "{25518ACB-AC00-4DE7-7F61-2756A5F47A38}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.Build.0 = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.Build.0 = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.Build.0 = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.Build.0 = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.Build.0 = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.Build.0 = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.Build.0 = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {962E7F03-8B12-5802-91AA-105EEC2060E4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {25518ACB-AC00-4DE7-7F61-2756A5F47A38} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829} + EndGlobalSection +EndGlobal diff --git a/run-aspire.cmd b/run-aspire.cmd new file mode 100644 index 0000000..9fceda7 --- /dev/null +++ b/run-aspire.cmd @@ -0,0 +1,42 @@ +@echo off +setlocal + +rem Starts the whole app: game server, Vite client and the Aspire dashboard. +rem Arguments are passed through, e.g. run-aspire.cmd --launch-profile http + +cd /d "%~dp0" + +where dotnet >nul 2>&1 +if errorlevel 1 ( + echo [run-aspire] dotnet SDK not found in PATH. + echo Install .NET 10: https://dotnet.microsoft.com/download + call :maybe_pause + exit /b 1 +) + +where node >nul 2>&1 +if errorlevel 1 ( + echo [run-aspire] Node.js not found in PATH - the client resource will fail to start. + echo Install Node 22.12 or newer: https://nodejs.org + echo. +) + +echo [run-aspire] Starting the Aspire AppHost. Press Ctrl+C to shut everything down. +echo. + +dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" %* +set "EXITCODE=%ERRORLEVEL%" + +if not "%EXITCODE%"=="0" ( + echo. + echo [run-aspire] AppHost exited with code %EXITCODE%. +) + +call :maybe_pause +endlocal & exit /b %EXITCODE% + +rem Keeps the window open when the file was double-clicked from Explorer. +:maybe_pause +echo %cmdcmdline% | find /i "%~nx0" >nul +if not errorlevel 1 pause +exit /b 0 diff --git a/src/HSchool.AppHost/AppHost.cs b/src/HSchool.AppHost/AppHost.cs new file mode 100644 index 0000000..a6569b5 --- /dev/null +++ b/src/HSchool.AppHost/AppHost.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; + +var builder = DistributedApplication.CreateBuilder(args); + +var server = builder.AddProject("server") + .WithHttpHealthCheck("/health") + .WithExternalHttpEndpoints(); + +// Integration tests and CI run headless: no Node, no dev server, just the game server. +var headless = builder.Configuration.GetValue("HSchool:Headless", false); + +if (!headless) +{ + var client = builder.AddViteApp("client", "../HSchool.Client") + .WithReference(server) + .WaitFor(server); + + // On publish the built client is copied into the server image and served from wwwroot. + server.PublishWithContainerFiles(client, "wwwroot"); +} + +builder.Build().Run(); diff --git a/src/HSchool.AppHost/HSchool.AppHost.csproj b/src/HSchool.AppHost/HSchool.AppHost.csproj new file mode 100644 index 0000000..a694386 --- /dev/null +++ b/src/HSchool.AppHost/HSchool.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + HSchool.AppHost + hschool-apphost-8f2c1d4a + true + + + + + + + + + + + + diff --git a/src/HSchool.AppHost/Properties/launchSettings.json b/src/HSchool.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..8d503e1 --- /dev/null +++ b/src/HSchool.AppHost/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17180;http://localhost:15180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21180", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23180", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22180" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19180", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18180", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20180" + } + } + } +} diff --git a/src/HSchool.AppHost/appsettings.Development.json b/src/HSchool.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/HSchool.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/HSchool.AppHost/appsettings.json b/src/HSchool.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/HSchool.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/HSchool.Client/.gitignore b/src/HSchool.Client/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/src/HSchool.Client/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/src/HSchool.Client/index.html b/src/HSchool.Client/index.html new file mode 100644 index 0000000..fd5dca6 --- /dev/null +++ b/src/HSchool.Client/index.html @@ -0,0 +1,19 @@ + + + + + + h-school + + + +
+
+ connecting… + tick 0 + -- ms + 0 entities +
+ + + diff --git a/src/HSchool.Client/package-lock.json b/src/HSchool.Client/package-lock.json new file mode 100644 index 0000000..1e1ca33 --- /dev/null +++ b/src/HSchool.Client/package-lock.json @@ -0,0 +1,1313 @@ +{ + "name": "hschool-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hschool-client", + "version": "0.1.0", + "dependencies": { + "pixi.js": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "~5.9.3", + "vite": "^8.2.1", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", + "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixi.js": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz", + "integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.13", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/src/HSchool.Client/package.json b/src/HSchool.Client/package.json new file mode 100644 index 0000000..60d881e --- /dev/null +++ b/src/HSchool.Client/package.json @@ -0,0 +1,26 @@ +{ + "name": "hschool-client", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "pixi.js": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "~5.9.3", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } +} diff --git a/src/HSchool.Client/src/game/hud.ts b/src/HSchool.Client/src/game/hud.ts new file mode 100644 index 0000000..01f5786 --- /dev/null +++ b/src/HSchool.Client/src/game/hud.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..b359c43 --- /dev/null +++ b/src/HSchool.Client/src/game/input.ts @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..4bff030 --- /dev/null +++ b/src/HSchool.Client/src/game/renderer.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..c25b780 --- /dev/null +++ b/src/HSchool.Client/src/game/snapshotBuffer.test.ts @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..63faa47 --- /dev/null +++ b/src/HSchool.Client/src/game/snapshotBuffer.ts @@ -0,0 +1,109 @@ +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 new file mode 100644 index 0000000..9eeecf4 --- /dev/null +++ b/src/HSchool.Client/src/main.ts @@ -0,0 +1,72 @@ +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(); diff --git a/src/HSchool.Client/src/net/connection.ts b/src/HSchool.Client/src/net/connection.ts new file mode 100644 index 0000000..100a1c9 --- /dev/null +++ b/src/HSchool.Client/src/net/connection.ts @@ -0,0 +1,158 @@ +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}`; +} diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts new file mode 100644 index 0000000..0ba0764 --- /dev/null +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -0,0 +1,116 @@ +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); + }); +}); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts new file mode 100644 index 0000000..9485a62 --- /dev/null +++ b/src/HSchool.Client/src/net/protocol.ts @@ -0,0 +1,182 @@ +/** + * 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}.`); + } +} diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css new file mode 100644 index 0000000..81df0fc --- /dev/null +++ b/src/HSchool.Client/src/style.css @@ -0,0 +1,34 @@ +:root { + color-scheme: dark; + font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + overflow: hidden; + background: #10141c; + color: #d7e0ef; +} + +#stage canvas { + display: block; +} + +#hud { + position: fixed; + top: 12px; + left: 12px; + display: flex; + gap: 16px; + padding: 8px 14px; + border: 1px solid #2a3242; + border-radius: 8px; + background: rgba(16, 20, 28, 0.72); + font-size: 13px; + letter-spacing: 0.02em; + pointer-events: none; +} diff --git a/src/HSchool.Client/src/vite-env.d.ts b/src/HSchool.Client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/HSchool.Client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/HSchool.Client/tsconfig.app.json b/src/HSchool.Client/tsconfig.app.json new file mode 100644 index 0000000..1cd76a8 --- /dev/null +++ b/src/HSchool.Client/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/src/HSchool.Client/tsconfig.json b/src/HSchool.Client/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/src/HSchool.Client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/src/HSchool.Client/tsconfig.node.json b/src/HSchool.Client/tsconfig.node.json new file mode 100644 index 0000000..0244509 --- /dev/null +++ b/src/HSchool.Client/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/src/HSchool.Client/vite.config.ts b/src/HSchool.Client/vite.config.ts new file mode 100644 index 0000000..fbbf710 --- /dev/null +++ b/src/HSchool.Client/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; + +// Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference, +// so the dev server proxies to whatever port the backend actually got. +const backend = process.env.SERVER_HTTPS ?? process.env.SERVER_HTTP ?? 'http://localhost:5180'; + +export default defineConfig({ + server: { + proxy: { + '/api': { + target: backend, + changeOrigin: true, + secure: false, + }, + '/ws': { + target: backend, + changeOrigin: true, + secure: false, + ws: true, + }, + }, + }, + build: { + target: 'es2022', + sourcemap: true, + }, +}); diff --git a/src/HSchool.Protocol/EntityKind.cs b/src/HSchool.Protocol/EntityKind.cs new file mode 100644 index 0000000..7fa7ebe --- /dev/null +++ b/src/HSchool.Protocol/EntityKind.cs @@ -0,0 +1,9 @@ +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/HSchool.Protocol.csproj b/src/HSchool.Protocol/HSchool.Protocol.csproj new file mode 100644 index 0000000..d43fc61 --- /dev/null +++ b/src/HSchool.Protocol/HSchool.Protocol.csproj @@ -0,0 +1,7 @@ + + + + HSchool.Protocol + + + diff --git a/src/HSchool.Protocol/InputButtons.cs b/src/HSchool.Protocol/InputButtons.cs new file mode 100644 index 0000000..52e1c0a --- /dev/null +++ b/src/HSchool.Protocol/InputButtons.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..a78275d --- /dev/null +++ b/src/HSchool.Protocol/MessageType.cs @@ -0,0 +1,18 @@ +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, +} diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs new file mode 100644 index 0000000..442bba0 --- /dev/null +++ b/src/HSchool.Protocol/Messages.cs @@ -0,0 +1,37 @@ +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); + +/// 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); + +/// 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); + +/// Answer to , carrying the current server tick. +public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick); diff --git a/src/HSchool.Protocol/PacketReader.cs b/src/HSchool.Protocol/PacketReader.cs new file mode 100644 index 0000000..6b5ede4 --- /dev/null +++ b/src/HSchool.Protocol/PacketReader.cs @@ -0,0 +1,75 @@ +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."); + } + } +} diff --git a/src/HSchool.Protocol/PacketWriter.cs b/src/HSchool.Protocol/PacketWriter.cs new file mode 100644 index 0000000..930dc29 --- /dev/null +++ b/src/HSchool.Protocol/PacketWriter.cs @@ -0,0 +1,80 @@ +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}."); + } + } +} diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs new file mode 100644 index 0000000..6c52746 --- /dev/null +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -0,0 +1,171 @@ +namespace HSchool.Protocol; + +/// +/// The single place where the wire format is defined on the .NET side. +/// Every change here must be mirrored in src/HSchool.Client/src/net/protocol.ts +/// and documented in docs/protocol.md. +/// +public static class ProtocolCodec +{ + 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; + } + + public static int WritePing(Span destination, in ClientPingMessage message) + { + var writer = new PacketWriter(destination); + writer.WriteMessageType(MessageType.ClientPing); + writer.WriteInt64(message.ClientTimeMs); + 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); + return writer.Position; + } + + public static int WritePong(Span destination, in ServerPongMessage message) + { + var writer = new PacketWriter(destination); + writer.WriteMessageType(MessageType.ServerPong); + writer.WriteInt64(message.ClientTimeMs); + writer.WriteUInt32(message.ServerTick); + 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) + { + 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); + } + + 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 MessageType PeekMessageType(ReadOnlySpan source) => + source.IsEmpty ? MessageType.None : (MessageType)source[0]; + + public static ClientHelloMessage ReadHello(ReadOnlySpan source) + { + 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); + } + + public static ClientPingMessage ReadPing(ReadOnlySpan source) + { + var reader = new PacketReader(source); + Expect(ref reader, MessageType.ClientPing); + return new ClientPingMessage(reader.ReadInt64()); + } + + 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); + } + + public static ServerPongMessage ReadPong(ReadOnlySpan source) + { + var reader = new PacketReader(source); + Expect(ref reader, MessageType.ServerPong); + var clientTime = reader.ReadInt64(); + var serverTick = reader.ReadUInt32(); + 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) + { + var reader = new PacketReader(source); + Expect(ref reader, MessageType.ServerSnapshot); + tick = reader.ReadUInt32(); + var count = reader.ReadUInt16(); + + if (count > destination.Length) + { + throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}."); + } + + 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; + } + + private static void Expect(ref PacketReader reader, MessageType expected) + { + var actual = reader.ReadMessageType(); + if (actual != expected) + { + throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}."); + } + } +} diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs new file mode 100644 index 0000000..4bc7c25 --- /dev/null +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -0,0 +1,20 @@ +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; +} diff --git a/src/HSchool.Protocol/ProtocolException.cs b/src/HSchool.Protocol/ProtocolException.cs new file mode 100644 index 0000000..71c98fa --- /dev/null +++ b/src/HSchool.Protocol/ProtocolException.cs @@ -0,0 +1,4 @@ +namespace HSchool.Protocol; + +/// Thrown when a frame is truncated, oversized or otherwise unreadable. +public sealed class ProtocolException(string message) : Exception(message); diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs new file mode 100644 index 0000000..c546b6a --- /dev/null +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -0,0 +1,20 @@ +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; +} diff --git a/src/HSchool.Server/Game/GameCommandQueue.cs b/src/HSchool.Server/Game/GameCommandQueue.cs new file mode 100644 index 0000000..680228c --- /dev/null +++ b/src/HSchool.Server/Game/GameCommandQueue.cs @@ -0,0 +1,13 @@ +using System.Collections.Concurrent; + +namespace HSchool.Server.Game; + +/// Multi-producer, single-consumer inbox drained at the start of every tick. +internal sealed class GameCommandQueue +{ + private readonly ConcurrentQueue _commands = new(); + + public void Enqueue(GameCommand command) => _commands.Enqueue(command); + + public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!); +} diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs new file mode 100644 index 0000000..9a8d83e --- /dev/null +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -0,0 +1,153 @@ +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); + } + } +} diff --git a/src/HSchool.Server/Game/GameMetrics.cs b/src/HSchool.Server/Game/GameMetrics.cs new file mode 100644 index 0000000..503061d --- /dev/null +++ b/src/HSchool.Server/Game/GameMetrics.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.Metrics; + +namespace HSchool.Server.Game; + +/// Game-loop counters surfaced in the Aspire dashboard. +internal sealed class GameMetrics : IDisposable +{ + public const string MeterName = "HSchool.Server.Game"; + + private readonly Meter _meter; + private readonly Counter _ticks; + private readonly Histogram _tickDuration; + private readonly UpDownCounter _connectedPlayers; + private readonly Counter _snapshotBytes; + + 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."); + } + + public void RecordTick(double durationMs) + { + _ticks.Add(1); + _tickDuration.Record(durationMs); + } + + public void PlayerJoined() => _connectedPlayers.Add(1); + + public void PlayerLeft() => _connectedPlayers.Add(-1); + + public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients); + + public void Dispose() => _meter.Dispose(); +} diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj new file mode 100644 index 0000000..ee0dc01 --- /dev/null +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -0,0 +1,16 @@ + + + + HSchool.Server + + + + + + + + + + + + diff --git a/src/HSchool.Server/Net/ClientRegistry.cs b/src/HSchool.Server/Net/ClientRegistry.cs new file mode 100644 index 0000000..532bce5 --- /dev/null +++ b/src/HSchool.Server/Net/ClientRegistry.cs @@ -0,0 +1,42 @@ +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; + } +} diff --git a/src/HSchool.Server/Net/GameClient.cs b/src/HSchool.Server/Net/GameClient.cs new file mode 100644 index 0000000..0da90ff --- /dev/null +++ b/src/HSchool.Server/Net/GameClient.cs @@ -0,0 +1,58 @@ +using System.Net.WebSockets; +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. +/// +internal sealed class GameClient(uint playerId, WebSocket socket) +{ + private const int OutboxCapacity = 32; + + private readonly Channel> _outbox = + Channel.CreateBounded>(new BoundedChannelOptions(OutboxCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + private bool _ready; + + 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. + /// + public bool IsReady => Volatile.Read(ref _ready); + + public void MarkReady() => Volatile.Write(ref _ready, true); + + /// Queues a frame. Returns false once the connection is shutting down. + public bool TrySend(ReadOnlyMemory frame) => _outbox.Writer.TryWrite(frame); + + /// Pumps queued frames to the socket until cancelled or the outbox completes. + public async Task RunSendLoopAsync(CancellationToken cancellationToken) + { + await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + if (Socket.State != WebSocketState.Open) + { + break; + } + + await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) + .ConfigureAwait(false); + } + } + + public void CompleteOutbox() => _outbox.Writer.TryComplete(); +} diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs new file mode 100644 index 0000000..bc07e08 --- /dev/null +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -0,0 +1,257 @@ +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. +/// +internal sealed class GameSocketHandler( + ClientRegistry clients, + GameCommandQueue commands, + GameLoopService loop, + ILogger logger) +{ + private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5); + + public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken) + { + var client = clients.Add(socket); + var buffer = ArrayPool.Shared.Rent(ProtocolConstants.MaxMessageSize); + using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task? sendLoop = null; + var joined = false; + + try + { + using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token); + handshakeCts.CancelAfter(HandshakeTimeout); + + var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false); + if (helloLength <= 0) + { + return; + } + + var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength)); + if (hello.ProtocolVersion != ProtocolConstants.Version) + { + logger.LogWarning( + "Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.", + client.PlayerId, + hello.ProtocolVersion, + ProtocolConstants.Version); + + await CloseAsync( + socket, + WebSocketCloseStatus.ProtocolError, + $"Protocol v{ProtocolConstants.Version} required.", + cancellationToken).ConfigureAwait(false); + 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); + client.MarkReady(); + + // From here on every outbound frame goes through the outbox, so there is + // exactly one writer on the socket. + sendLoop = client.RunSendLoopAsync(connectionCts.Token); + + await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Client went away or the host is shutting down. + } + catch (ProtocolException ex) + { + logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId); + await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None) + .ConfigureAwait(false); + } + catch (WebSocketException ex) + { + logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId); + } + finally + { + ArrayPool.Shared.Return(buffer); + clients.Remove(client.PlayerId); + client.CompleteOutbox(); + + if (joined) + { + commands.Enqueue(new GameCommand.Leave(client.PlayerId)); + } + + if (sendLoop is not null) + { + try + { + await sendLoop.ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) + { + // Expected while tearing the connection down. + } + } + + await connectionCts.CancelAsync().ConfigureAwait(false); + } + } + + private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false); + if (length <= 0) + { + return; + } + + 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)); + break; + + case MessageType.ClientPing: + var ping = ProtocolCodec.ReadPing(frame); + SendPong(client, ping.ClientTimeMs); + break; + + default: + logger.LogDebug( + "Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.", + frame[0], + client.PlayerId); + break; + } + } + } + + /// Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame. + private async Task ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken) + { + var offset = 0; + + while (true) + { + var result = await socket + .ReceiveAsync(new ArraySegment(buffer, offset, buffer.Length - offset), cancellationToken) + .ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + return 0; + } + + if (result.MessageType != WebSocketMessageType.Binary) + { + logger.LogDebug("Dropping non-binary frame."); + return -1; + } + + offset += result.Count; + + if (result.EndOfMessage) + { + return offset; + } + + if (offset >= buffer.Length) + { + logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize); + await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken) + .ConfigureAwait(false); + return -1; + } + } + } + + private async Task SendWelcomeAsync(WebSocket socket, uint entityId, 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); + + await socket + .SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) + .ConfigureAwait(false); + } + + private void SendPong(GameClient client, long clientTimeMs) + { + var frame = new byte[16]; + var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick)); + client.TrySend(frame.AsMemory(0, length)); + } + + private static async Task CloseAsync( + WebSocket socket, + WebSocketCloseStatus status, + string description, + CancellationToken cancellationToken) + { + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + try + { + await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException) + { + // The peer may already be gone; nothing left to do. + } + } + } + + /// 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 new file mode 100644 index 0000000..d19cd9a --- /dev/null +++ b/src/HSchool.Server/Program.cs @@ -0,0 +1,88 @@ +using System.Net.WebSockets; +using HSchool.Server.Game; +using HSchool.Server.Net; +using HSchool.Simulation; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +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.") + .ValidateOnStart(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName)); + +var app = builder.Build(); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseWebSockets(new WebSocketOptions +{ + KeepAliveInterval = TimeSpan.FromSeconds(30), +}); + +var api = app.MapGroup("/api"); + +api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) => +{ + var options = loop.Options; + return new GameStatusResponse( + loop.CurrentTick, + options.TickRate, + loop.PlayerCount, + clients.Count, + options.WorldWidth, + options.WorldHeight); +}) +.WithName("GetGameStatus"); + +// The realtime channel: one binary frame per protocol message, see docs/protocol.md. +app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) => +{ + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade."); + return; + } + + using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); + await handler.HandleAsync(socket, context.RequestAborted); +}); + +app.MapDefaultEndpoints(); + +// In a published container the built client lands in wwwroot next to the server. +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); + +/// Exposed so WebApplicationFactory-style tests can reference the entry point. +public partial class Program; diff --git a/src/HSchool.Server/Properties/launchSettings.json b/src/HSchool.Server/Properties/launchSettings.json new file mode 100644 index 0000000..0a704f4 --- /dev/null +++ b/src/HSchool.Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7180;http://localhost:5180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/HSchool.Server/appsettings.Development.json b/src/HSchool.Server/appsettings.Development.json new file mode 100644 index 0000000..2b6d491 --- /dev/null +++ b/src/HSchool.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "HSchool.Server.Game": "Information" + } + } +} diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json new file mode 100644 index 0000000..e542d70 --- /dev/null +++ b/src/HSchool.Server/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Simulation": { + "TickRate": 20, + "WorldWidth": 1600, + "WorldHeight": 900, + "PlayerSpeed": 260, + "PlayerRadius": 18 + } +} diff --git a/src/HSchool.ServiceDefaults/Extensions.cs b/src/HSchool.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..37afc3d --- /dev/null +++ b/src/HSchool.ServiceDefaults/Extensions.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Common Aspire wiring: service discovery, resilience, health checks and OpenTelemetry. +/// Referenced by every service project in the solution. +/// See https://aka.ms/dotnet/aspire/service-defaults. +/// +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(options => + // Health probes would drown out the game traffic. + options.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)) + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Exposing health endpoints outside development has security implications: + // https://aka.ms/dotnet/aspire/healthchecks + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live"), + }); + } + + return app; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } +} diff --git a/src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj b/src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj new file mode 100644 index 0000000..67c046c --- /dev/null +++ b/src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + HSchool.ServiceDefaults + true + + + + + + + + + + + + + + + diff --git a/src/HSchool.Simulation/Components/NetworkId.cs b/src/HSchool.Simulation/Components/NetworkId.cs new file mode 100644 index 0000000..f9d29f8 --- /dev/null +++ b/src/HSchool.Simulation/Components/NetworkId.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..d49f13d --- /dev/null +++ b/src/HSchool.Simulation/Components/PlayerControl.cs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..2a8757e --- /dev/null +++ b/src/HSchool.Simulation/Components/Position.cs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..5522483 --- /dev/null +++ b/src/HSchool.Simulation/Components/Renderable.cs @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..eaf5a80 --- /dev/null +++ b/src/HSchool.Simulation/Components/Velocity.cs @@ -0,0 +1,14 @@ +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/GameWorld.cs b/src/HSchool.Simulation/GameWorld.cs new file mode 100644 index 0000000..3a6bbad --- /dev/null +++ b/src/HSchool.Simulation/GameWorld.cs @@ -0,0 +1,199 @@ +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 new file mode 100644 index 0000000..9e0b87d --- /dev/null +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -0,0 +1,16 @@ + + + + HSchool.Simulation + true + + + + + + + + + + + diff --git a/src/HSchool.Simulation/ISimulationSystem.cs b/src/HSchool.Simulation/ISimulationSystem.cs new file mode 100644 index 0000000..9a7067d --- /dev/null +++ b/src/HSchool.Simulation/ISimulationSystem.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..fce3578 --- /dev/null +++ b/src/HSchool.Simulation/Palette.cs @@ -0,0 +1,22 @@ +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/SimulationContext.cs b/src/HSchool.Simulation/SimulationContext.cs new file mode 100644 index 0000000..c05b385 --- /dev/null +++ b/src/HSchool.Simulation/SimulationContext.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..3adf433 --- /dev/null +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -0,0 +1,23 @@ +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); +} diff --git a/src/HSchool.Simulation/Systems/MovementSystem.cs b/src/HSchool.Simulation/Systems/MovementSystem.cs new file mode 100644 index 0000000..420b939 --- /dev/null +++ b/src/HSchool.Simulation/Systems/MovementSystem.cs @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..c854c13 --- /dev/null +++ b/src/HSchool.Simulation/Systems/PlayerInputSystem.cs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..3e507cd --- /dev/null +++ b/src/HSchool.Simulation/Systems/WorldBoundsSystem.cs @@ -0,0 +1,47 @@ +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/AppHostFixture.cs b/tests/HSchool.AppHost.Tests/AppHostFixture.cs new file mode 100644 index 0000000..76b4994 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/AppHostFixture.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; + +namespace HSchool.AppHost.Tests; + +/// +/// Boots the AppHost once for the whole suite — starting it per test costs about ten +/// seconds each. The client resource is skipped (HSchool:Headless), so the tests +/// need no Node install. +/// +public sealed class AppHostFixture : IAsyncLifetime +{ + private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120); + + private DistributedApplication? _app; + + public DistributedApplication App => + _app ?? throw new InvalidOperationException("The AppHost has not been started."); + + public async ValueTask InitializeAsync() + { + var appHost = await DistributedApplicationTestingBuilder + .CreateAsync(["--HSchool:Headless=true"], CancellationToken.None); + + appHost.Services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Warning); + logging.AddFilter("Aspire.", LogLevel.Warning); + }); + + _app = await appHost.BuildAsync().WaitAsync(StartupTimeout); + await _app.StartAsync().WaitAsync(StartupTimeout); + await _app.ResourceNotifications + .WaitForResourceHealthyAsync("server", CancellationToken.None) + .WaitAsync(StartupTimeout); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + } +} + +/// Groups every integration test around the single AppHost instance. +[CollectionDefinition(Name)] +public sealed class AppHostCollection : ICollectionFixture +{ + public const string Name = "apphost"; +} diff --git a/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs b/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs new file mode 100644 index 0000000..d1ed321 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/GameServerIntegrationTests.cs @@ -0,0 +1,243 @@ +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/HSchool.AppHost.Tests.csproj b/tests/HSchool.AppHost.Tests/HSchool.AppHost.Tests.csproj new file mode 100644 index 0000000..e825d03 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/HSchool.AppHost.Tests.csproj @@ -0,0 +1,29 @@ + + + + HSchool.AppHost.Tests + true + Exe + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj b/tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj new file mode 100644 index 0000000..94e67b6 --- /dev/null +++ b/tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj @@ -0,0 +1,23 @@ + + + + HSchool.Protocol.Tests + true + Exe + + + + + + + + + + + + + + + + + diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs new file mode 100644 index 0000000..2e11d3d --- /dev/null +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -0,0 +1,157 @@ +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)); + } +} diff --git a/tests/HSchool.Simulation.Tests/GameWorldTests.cs b/tests/HSchool.Simulation.Tests/GameWorldTests.cs new file mode 100644 index 0000000..c5eea8a --- /dev/null +++ b/tests/HSchool.Simulation.Tests/GameWorldTests.cs @@ -0,0 +1,224 @@ +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/HSchool.Simulation.Tests.csproj b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj new file mode 100644 index 0000000..1b293c1 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj @@ -0,0 +1,23 @@ + + + + HSchool.Simulation.Tests + true + Exe + + + + + + + + + + + + + + + + +