Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -414,3 +414,7 @@ FodyWeavers.xsd
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
|
||||
# h-school
|
||||
src/HSchool.Client/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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 <random>`), 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 `<PackageReference Include="..." />` in the project.
|
||||
- Transitive pinning is on, so a downgrade warning means you bump the central version rather than
|
||||
adding a per-project override.
|
||||
- Keep the dependency count low. Arch, PixiJS, Aspire and the test runners are the whole budget;
|
||||
anything new needs a reason in the change description.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Tests opt in via <IsTestProject>; keeps `dotnet test` discovery predictable. -->
|
||||
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Label="ECS">
|
||||
<PackageVersion Include="Arch" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Aspire">
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.4.6" />
|
||||
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
|
||||
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.4.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Runtime">
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Testing">
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/HSchool.AppHost/HSchool.AppHost.csproj" />
|
||||
<Project Path="src/HSchool.Protocol/HSchool.Protocol.csproj" />
|
||||
<Project Path="src/HSchool.Server/HSchool.Server.csproj" />
|
||||
<Project Path="src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj" />
|
||||
<Project Path="src/HSchool.Simulation/HSchool.Simulation.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/HSchool.AppHost.Tests/HSchool.AppHost.Tests.csproj" />
|
||||
<Project Path="tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj" />
|
||||
<Project Path="tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "src/HSchool.AppHost/HSchool.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var server = builder.AddProject<Projects.HSchool_Server>("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();
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>HSchool.AppHost</RootNamespace>
|
||||
<UserSecretsId>hschool-apphost-8f2c1d4a</UserSecretsId>
|
||||
<IsAspireHost>true</IsAspireHost>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" />
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Server\HSchool.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>h-school</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<div id="hud">
|
||||
<span data-hud="status">connecting…</span>
|
||||
<span data-hud="tick">tick 0</span>
|
||||
<span data-hud="ping">-- ms</span>
|
||||
<span data-hud="entities">0 entities</span>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1313
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, HTMLElement>();
|
||||
|
||||
constructor(root: ParentNode = document) {
|
||||
for (const element of root.querySelectorAll<HTMLElement>('[data-hud]')) {
|
||||
this.fields.set(element.dataset['hud'] ?? '', element);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status: ConnectionStatus): void {
|
||||
this.set('status', status);
|
||||
}
|
||||
|
||||
setTick(tick: number): void {
|
||||
this.set('tick', `tick ${tick}`);
|
||||
}
|
||||
|
||||
setPing(rttMs: number): void {
|
||||
this.set('ping', `${Math.round(rttMs)} ms`);
|
||||
}
|
||||
|
||||
setEntityCount(count: number): void {
|
||||
this.set('entities', `${count} entities`);
|
||||
}
|
||||
|
||||
private set(field: string, text: string): void {
|
||||
const element = this.fields.get(field);
|
||||
if (element !== undefined) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, number>> = {
|
||||
KeyW: InputButtons.Up,
|
||||
ArrowUp: InputButtons.Up,
|
||||
KeyS: InputButtons.Down,
|
||||
ArrowDown: InputButtons.Down,
|
||||
KeyA: InputButtons.Left,
|
||||
ArrowLeft: InputButtons.Left,
|
||||
KeyD: InputButtons.Right,
|
||||
ArrowRight: InputButtons.Right,
|
||||
};
|
||||
|
||||
/** Tracks the keyboard and pushes the current mask on a fixed cadence. */
|
||||
export class InputTracker {
|
||||
private buttons = InputButtons.None;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons |= button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons &= ~button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Alt-tabbing away must not leave a key stuck down.
|
||||
private readonly onBlur = (): void => {
|
||||
this.buttons = InputButtons.None;
|
||||
};
|
||||
|
||||
constructor(private readonly send: (buttons: number) => void) {}
|
||||
|
||||
get current(): number {
|
||||
return this.buttons;
|
||||
}
|
||||
|
||||
start(target: Window = window): void {
|
||||
target.addEventListener('keydown', this.onKeyDown);
|
||||
target.addEventListener('keyup', this.onKeyUp);
|
||||
target.addEventListener('blur', this.onBlur);
|
||||
|
||||
this.timer = setInterval(() => this.send(this.buttons), 1000 / INPUT_SEND_HZ);
|
||||
}
|
||||
|
||||
stop(target: Window = window): void {
|
||||
target.removeEventListener('keydown', this.onKeyDown);
|
||||
target.removeEventListener('keyup', this.onKeyUp);
|
||||
target.removeEventListener('blur', this.onBlur);
|
||||
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number, Graphics>();
|
||||
|
||||
private worldWidth = 1600;
|
||||
private worldHeight = 900;
|
||||
private ownEntityId = 0;
|
||||
|
||||
constructor(private readonly app: Application) {
|
||||
this.world.addChild(this.field);
|
||||
this.app.stage.addChild(this.world);
|
||||
this.app.renderer.on('resize', () => this.layout());
|
||||
}
|
||||
|
||||
/** Called on every welcome frame: the server owns the field size. */
|
||||
configure(worldWidth: number, worldHeight: number, ownEntityId: number): void {
|
||||
this.worldWidth = worldWidth;
|
||||
this.worldHeight = worldHeight;
|
||||
this.ownEntityId = ownEntityId;
|
||||
|
||||
this.field
|
||||
.clear()
|
||||
.rect(0, 0, worldWidth, worldHeight)
|
||||
.stroke({ color: FIELD_BORDER_COLOR, width: 4 });
|
||||
|
||||
this.layout();
|
||||
}
|
||||
|
||||
draw(entities: readonly EntitySnapshot[]): void {
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const entity of entities) {
|
||||
seen.add(entity.id);
|
||||
|
||||
let sprite = this.sprites.get(entity.id);
|
||||
if (sprite === undefined) {
|
||||
sprite = this.createSprite(entity);
|
||||
this.sprites.set(entity.id, sprite);
|
||||
this.world.addChild(sprite);
|
||||
}
|
||||
|
||||
sprite.x = entity.x;
|
||||
sprite.y = entity.y;
|
||||
}
|
||||
|
||||
for (const [id, sprite] of this.sprites) {
|
||||
if (!seen.has(id)) {
|
||||
sprite.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createSprite(entity: EntitySnapshot): Graphics {
|
||||
const sprite = new Graphics();
|
||||
|
||||
if (entity.kind === EntityKind.Obstacle) {
|
||||
sprite.roundRect(-entity.radius, -entity.radius, entity.radius * 2, entity.radius * 2, 8);
|
||||
} else {
|
||||
sprite.circle(0, 0, entity.radius);
|
||||
}
|
||||
|
||||
sprite.fill({ color: entity.color });
|
||||
|
||||
if (entity.id === this.ownEntityId) {
|
||||
sprite.circle(0, 0, entity.radius + 6).stroke({ color: OWN_PLAYER_RING_COLOR, width: 2, alpha: 0.9 });
|
||||
}
|
||||
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/** Fits the whole field on screen with letterboxing, so every client sees the same area. */
|
||||
private layout(): void {
|
||||
const { width, height } = this.app.renderer.screen;
|
||||
const scale = Math.min(width / this.worldWidth, height / this.worldHeight) * 0.95;
|
||||
|
||||
this.world.scale.set(scale);
|
||||
this.world.x = (width - this.worldWidth * scale) / 2;
|
||||
this.world.y = (height - this.worldHeight * scale) / 2;
|
||||
}
|
||||
|
||||
/** Drops every sprite, e.g. after a reconnect assigns new replication ids. */
|
||||
reset(): void {
|
||||
for (const sprite of this.sprites.values()) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.sprites.clear();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | 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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}.`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
|
||||
public enum EntityKind : byte
|
||||
{
|
||||
Unknown = 0,
|
||||
Player = 1,
|
||||
Obstacle = 2,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Protocol</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
|
||||
[Flags]
|
||||
public enum InputButtons : byte
|
||||
{
|
||||
None = 0,
|
||||
Up = 1 << 0,
|
||||
Down = 1 << 1,
|
||||
Left = 1 << 2,
|
||||
Right = 1 << 3,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum MessageType : byte
|
||||
{
|
||||
None = 0x00,
|
||||
|
||||
ClientHello = 0x01,
|
||||
ClientInput = 0x02,
|
||||
ClientPing = 0x03,
|
||||
|
||||
ServerWelcome = 0x81,
|
||||
ServerSnapshot = 0x82,
|
||||
ServerPong = 0x83,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
|
||||
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
|
||||
|
||||
/// <summary>
|
||||
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
|
||||
/// in future snapshots once client-side prediction lands.
|
||||
/// </summary>
|
||||
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
|
||||
|
||||
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
|
||||
public readonly record struct ClientPingMessage(long ClientTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Sent once per connection, before the first snapshot.
|
||||
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
|
||||
/// so the renderer can tell it apart from everyone else.
|
||||
/// </summary>
|
||||
public readonly record struct ServerWelcomeMessage(
|
||||
byte ProtocolVersion,
|
||||
uint PlayerEntityId,
|
||||
byte TickRate,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
|
||||
public readonly record struct EntitySnapshot(
|
||||
uint Id,
|
||||
EntityKind Kind,
|
||||
float X,
|
||||
float Y,
|
||||
float Radius,
|
||||
uint Color);
|
||||
|
||||
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
|
||||
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
|
||||
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
|
||||
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
|
||||
/// </summary>
|
||||
public ref struct PacketWriter(Span<byte> buffer)
|
||||
{
|
||||
private readonly Span<byte> _buffer = buffer;
|
||||
private int _position = 0;
|
||||
|
||||
public readonly int Position => _position;
|
||||
|
||||
public readonly ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
|
||||
public void WriteShortString(string value)
|
||||
{
|
||||
var byteCount = Encoding.UTF8.GetByteCount(value);
|
||||
if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
|
||||
}
|
||||
|
||||
WriteByte((byte)byteCount);
|
||||
EnsureRoom(byteCount);
|
||||
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
|
||||
_position += byteCount;
|
||||
}
|
||||
|
||||
private readonly void EnsureRoom(int bytes)
|
||||
{
|
||||
if (_position + bytes > _buffer.Length)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The single place where the wire format is defined on the .NET side.
|
||||
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
|
||||
/// and documented in <c>docs/protocol.md</c>.
|
||||
/// </summary>
|
||||
public static class ProtocolCodec
|
||||
{
|
||||
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientHello);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteShortString(message.PlayerName);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WriteInput(Span<byte> destination, in ClientInputMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientInput);
|
||||
writer.WriteUInt32(message.Sequence);
|
||||
writer.WriteByte((byte)message.Buttons);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePing(Span<byte> 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<byte> destination, in ServerWelcomeMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerWelcome);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteUInt32(message.PlayerEntityId);
|
||||
writer.WriteByte(message.TickRate);
|
||||
writer.WriteSingle(message.WorldWidth);
|
||||
writer.WriteSingle(message.WorldHeight);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerPong);
|
||||
writer.WriteInt64(message.ClientTimeMs);
|
||||
writer.WriteUInt32(message.ServerTick);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
|
||||
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
|
||||
public static int SnapshotSize(int entityCount) =>
|
||||
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
|
||||
|
||||
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
|
||||
source.IsEmpty ? MessageType.None : (MessageType)source[0];
|
||||
|
||||
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> 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<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientInput);
|
||||
var sequence = reader.ReadUInt32();
|
||||
var buttons = (InputButtons)reader.ReadByte();
|
||||
return new ClientInputMessage(sequence, buttons);
|
||||
}
|
||||
|
||||
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientPing);
|
||||
return new ClientPingMessage(reader.ReadInt64());
|
||||
}
|
||||
|
||||
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerWelcome);
|
||||
var version = reader.ReadByte();
|
||||
var playerEntityId = reader.ReadUInt32();
|
||||
var tickRate = reader.ReadByte();
|
||||
var width = reader.ReadSingle();
|
||||
var height = reader.ReadSingle();
|
||||
return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
|
||||
}
|
||||
|
||||
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerPong);
|
||||
var clientTime = reader.ReadInt64();
|
||||
var serverTick = reader.ReadUInt32();
|
||||
return new ServerPongMessage(clientTime, serverTick);
|
||||
}
|
||||
|
||||
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
|
||||
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
|
||||
{
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||
public const byte Version = 1;
|
||||
|
||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||
public const int MaxMessageSize = 64 * 1024;
|
||||
|
||||
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
|
||||
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
|
||||
|
||||
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
|
||||
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
|
||||
|
||||
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
|
||||
public const int MaxPlayerNameBytes = 32;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Thrown when a frame is truncated, oversized or otherwise unreadable.</summary>
|
||||
public sealed class ProtocolException(string message) : Exception(message);
|
||||
@@ -0,0 +1,20 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Work item handed from a connection thread to the loop thread. The simulation is
|
||||
/// single-threaded, so every mutation arrives as one of these.
|
||||
/// </summary>
|
||||
internal abstract record GameCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
|
||||
/// with the replication id so the handler can send a Welcome frame.
|
||||
/// </summary>
|
||||
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
|
||||
|
||||
internal sealed record Leave(uint PlayerId) : GameCommand;
|
||||
|
||||
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
|
||||
internal sealed class GameCommandQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<GameCommand> _commands = new();
|
||||
|
||||
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
|
||||
|
||||
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
|
||||
/// drain commands, step the simulation, broadcast a full snapshot.
|
||||
/// The world is touched from this thread only.
|
||||
/// </summary>
|
||||
internal sealed class GameLoopService(
|
||||
IOptions<SimulationOptions> options,
|
||||
GameCommandQueue commands,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
|
||||
private const int MaxCatchUpSteps = 5;
|
||||
|
||||
private readonly SimulationOptions _options = options.Value;
|
||||
private readonly List<EntitySnapshot> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
|
||||
internal sealed class GameMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "HSchool.Server.Game";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _ticks;
|
||||
private readonly Histogram<double> _tickDuration;
|
||||
private readonly UpDownCounter<long> _connectedPlayers;
|
||||
private readonly Counter<long> _snapshotBytes;
|
||||
|
||||
public GameMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
_meter = meterFactory.Create(MeterName);
|
||||
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
|
||||
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
|
||||
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
|
||||
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Server</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace HSchool.Server.Net;
|
||||
|
||||
/// <summary>Tracks live connections and hands out player ids.</summary>
|
||||
internal sealed class ClientRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, GameClient> _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 _);
|
||||
|
||||
/// <summary>
|
||||
/// Queues the same frame for every client that finished its handshake; the buffer must not
|
||||
/// be reused afterwards.
|
||||
/// </summary>
|
||||
public int Broadcast(ReadOnlyMemory<byte> frame)
|
||||
{
|
||||
var recipients = 0;
|
||||
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
if (client.IsReady && client.TrySend(frame))
|
||||
{
|
||||
recipients++;
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace HSchool.Server.Net;
|
||||
|
||||
/// <summary>
|
||||
/// One connected browser. Frames are queued instead of written inline so a slow client
|
||||
/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
|
||||
/// which is exactly what you want for state that is resent 20 times a second.
|
||||
/// </summary>
|
||||
internal sealed class GameClient(uint playerId, WebSocket socket)
|
||||
{
|
||||
private const int OutboxCapacity = 32;
|
||||
|
||||
private readonly Channel<ReadOnlyMemory<byte>> _outbox =
|
||||
Channel.CreateBounded<ReadOnlyMemory<byte>>(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}";
|
||||
|
||||
/// <summary>
|
||||
/// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
|
||||
/// connection never sees world state before it knows its own entity id.
|
||||
/// </summary>
|
||||
public bool IsReady => Volatile.Read(ref _ready);
|
||||
|
||||
public void MarkReady() => Volatile.Write(ref _ready, true);
|
||||
|
||||
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
|
||||
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
|
||||
|
||||
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Drives one WebSocket connection: handshake, join, then the receive loop.
|
||||
/// Everything it learns from the wire is untrusted, so frames are validated before
|
||||
/// they reach the simulation.
|
||||
/// </summary>
|
||||
internal sealed class GameSocketHandler(
|
||||
ClientRegistry clients,
|
||||
GameCommandQueue commands,
|
||||
GameLoopService loop,
|
||||
ILogger<GameSocketHandler> 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<byte>.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<uint>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
commands.Enqueue(join);
|
||||
|
||||
var entityId = await join.EntityId.Task
|
||||
.WaitAsync(HandshakeTimeout, connectionCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
joined = true;
|
||||
|
||||
await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
|
||||
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<byte>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
|
||||
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket
|
||||
.ReceiveAsync(new ArraySegment<byte>(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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
|
||||
private static string SanitizeName(string name, uint playerId)
|
||||
{
|
||||
var trimmed = name.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return $"player-{playerId}";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
foreach (var character in trimmed)
|
||||
{
|
||||
builder.Append(char.IsControl(character) ? ' ' : character);
|
||||
}
|
||||
|
||||
var sanitized = builder.ToString();
|
||||
return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
|
||||
? sanitized
|
||||
: sanitized[..ProtocolConstants.MaxPlayerNameBytes];
|
||||
}
|
||||
}
|
||||
@@ -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<SimulationOptions>()
|
||||
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
|
||||
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
|
||||
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
|
||||
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();
|
||||
|
||||
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
|
||||
internal sealed record GameStatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||
public partial class Program;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"HSchool.Server.Game": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Simulation": {
|
||||
"TickRate": 20,
|
||||
"WorldWidth": 1600,
|
||||
"WorldHeight": 900,
|
||||
"PlayerSpeed": 260,
|
||||
"PlayerRadius": 18
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(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<TBuilder>(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<TBuilder>(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<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.ServiceDefaults</RootNamespace>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Stable replication id. Arch entity ids are recycled, so the client gets this
|
||||
/// monotonically increasing value instead.
|
||||
/// </summary>
|
||||
public struct NetworkId
|
||||
{
|
||||
public uint Value;
|
||||
|
||||
public NetworkId(uint value) => Value = value;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Marks an entity as driven by a connected client's input.</summary>
|
||||
public struct PlayerControl
|
||||
{
|
||||
/// <summary>Network id of the owning connection.</summary>
|
||||
public uint PlayerId;
|
||||
|
||||
/// <summary>Latest intent received from that connection.</summary>
|
||||
public InputButtons Buttons;
|
||||
|
||||
/// <summary>Sequence number of that intent; reserved for prediction/reconciliation.</summary>
|
||||
public uint LastInputSequence;
|
||||
|
||||
/// <summary>Movement speed in units per second.</summary>
|
||||
public float Speed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>World-space position in simulation units.</summary>
|
||||
public struct Position
|
||||
{
|
||||
public float X;
|
||||
public float Y;
|
||||
|
||||
public Position(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Everything the client needs to draw the entity; replicated verbatim in snapshots.</summary>
|
||||
public struct Renderable
|
||||
{
|
||||
public EntityKind Kind;
|
||||
public float Radius;
|
||||
|
||||
/// <summary>Packed 0x00RRGGBB.</summary>
|
||||
public uint Color;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Simulation units per second, integrated by <c>MovementSystem</c>.</summary>
|
||||
public struct Velocity
|
||||
{
|
||||
public float X;
|
||||
public float Y;
|
||||
|
||||
public Velocity(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Simulation.Components;
|
||||
using HSchool.Simulation.Systems;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative world: an Arch <see cref="World"/> plus the fixed-step system pipeline.
|
||||
/// Not thread-safe by design — only the game loop thread may touch it, everything else
|
||||
/// goes through the command queue in the server layer.
|
||||
/// </summary>
|
||||
public sealed class GameWorld : IDisposable
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly ISimulationSystem[] _systems;
|
||||
private readonly Dictionary<uint, Entity> _playerEntities = [];
|
||||
|
||||
private uint _nextNetworkId = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public GameWorld(SimulationOptions? options = null)
|
||||
{
|
||||
Options = options ?? new SimulationOptions();
|
||||
_world = World.Create();
|
||||
_systems =
|
||||
[
|
||||
new PlayerInputSystem(),
|
||||
new MovementSystem(),
|
||||
new WorldBoundsSystem(),
|
||||
];
|
||||
|
||||
SpawnObstacles();
|
||||
}
|
||||
|
||||
public SimulationOptions Options { get; }
|
||||
|
||||
/// <summary>Number of fixed steps simulated so far.</summary>
|
||||
public uint CurrentTick { get; private set; }
|
||||
|
||||
public int PlayerCount => _playerEntities.Count;
|
||||
|
||||
public int EntityCount => _world.CountEntities(new QueryDescription().WithAll<NetworkId>());
|
||||
|
||||
/// <summary>Adds a player body. Returns its replication id.</summary>
|
||||
public uint SpawnPlayer(uint playerId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_playerEntities.ContainsKey(playerId))
|
||||
{
|
||||
throw new InvalidOperationException($"Player {playerId} is already spawned.");
|
||||
}
|
||||
|
||||
var networkId = _nextNetworkId++;
|
||||
var (x, y) = SpawnPoint(playerId);
|
||||
|
||||
var entity = _world.Create(
|
||||
new NetworkId(networkId),
|
||||
new Position(x, y),
|
||||
new Velocity(0f, 0f),
|
||||
new PlayerControl
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Buttons = InputButtons.None,
|
||||
Speed = Options.PlayerSpeed,
|
||||
},
|
||||
new Renderable
|
||||
{
|
||||
Kind = EntityKind.Player,
|
||||
Radius = Options.PlayerRadius,
|
||||
Color = Palette.ForPlayer(playerId),
|
||||
});
|
||||
|
||||
_playerEntities[playerId] = entity;
|
||||
return networkId;
|
||||
}
|
||||
|
||||
public void DespawnPlayer(uint playerId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_playerEntities.Remove(playerId, out var entity) && _world.IsAlive(entity))
|
||||
{
|
||||
_world.Destroy(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the latest intent for a player; applied on the next tick.</summary>
|
||||
public void ApplyInput(uint playerId, InputButtons buttons, uint sequence)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!_playerEntities.TryGetValue(playerId, out var entity) || !_world.IsAlive(entity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ref var control = ref _world.Get<PlayerControl>(entity);
|
||||
|
||||
// Late/duplicate packets carry a stale sequence; the newest intent wins.
|
||||
if (sequence < control.LastInputSequence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
control.Buttons = buttons;
|
||||
control.LastInputSequence = sequence;
|
||||
}
|
||||
|
||||
/// <summary>Runs one fixed step of the pipeline.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
CurrentTick++;
|
||||
var context = new SimulationContext(CurrentTick, Options.FixedDeltaTime, Options);
|
||||
|
||||
foreach (var system in _systems)
|
||||
{
|
||||
system.Update(_world, in context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fills <paramref name="buffer"/> with the replicated state of every visible entity.</summary>
|
||||
public void CaptureSnapshot(List<EntitySnapshot> buffer)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
|
||||
buffer.Clear();
|
||||
|
||||
var query = new QueryDescription().WithAll<NetworkId, Position, Renderable>();
|
||||
_world.Query(in query, (ref NetworkId id, ref Position position, ref Renderable renderable) =>
|
||||
{
|
||||
buffer.Add(new EntitySnapshot(
|
||||
id.Value,
|
||||
renderable.Kind,
|
||||
position.X,
|
||||
position.Y,
|
||||
renderable.Radius,
|
||||
renderable.Color));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Replication id of a connected player, or <c>null</c> if it is not spawned.</summary>
|
||||
public uint? GetNetworkId(uint playerId) =>
|
||||
_playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
|
||||
? _world.Get<NetworkId>(entity).Value
|
||||
: null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
World.Destroy(_world);
|
||||
}
|
||||
|
||||
/// <summary>A few static blocks so an empty world still shows something on screen.</summary>
|
||||
private void SpawnObstacles()
|
||||
{
|
||||
ReadOnlySpan<(float X, float Y, float Radius)> layout =
|
||||
[
|
||||
(0.5f, 0.5f, 70f),
|
||||
(0.2f, 0.25f, 45f),
|
||||
(0.8f, 0.75f, 45f),
|
||||
];
|
||||
|
||||
foreach (var (relativeX, relativeY, radius) in layout)
|
||||
{
|
||||
_world.Create(
|
||||
new NetworkId(_nextNetworkId++),
|
||||
new Position(Options.WorldWidth * relativeX, Options.WorldHeight * relativeY),
|
||||
new Renderable
|
||||
{
|
||||
Kind = EntityKind.Obstacle,
|
||||
Radius = radius,
|
||||
Color = Palette.Obstacle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic spread of spawn points around the centre of the field.</summary>
|
||||
private (float X, float Y) SpawnPoint(uint playerId)
|
||||
{
|
||||
const int Slots = 8;
|
||||
var slot = (int)(playerId % Slots);
|
||||
var angle = slot * (2f * MathF.PI / Slots);
|
||||
var radius = MathF.Min(Options.WorldWidth, Options.WorldHeight) * 0.3f;
|
||||
|
||||
return (
|
||||
(Options.WorldWidth * 0.5f) + (MathF.Cos(angle) * radius),
|
||||
(Options.WorldHeight * 0.5f) + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Simulation</RootNamespace>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Arch" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Arch.Core;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// One stage of the fixed-step pipeline. Systems run in registration order on the
|
||||
/// loop thread and must not capture per-step state.
|
||||
/// </summary>
|
||||
public interface ISimulationSystem
|
||||
{
|
||||
void Update(World world, in SimulationContext context);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Stable colours for replicated entities, packed as 0x00RRGGBB.</summary>
|
||||
public static class Palette
|
||||
{
|
||||
public const uint Obstacle = 0x3A4553;
|
||||
|
||||
private static readonly uint[] PlayerColors =
|
||||
[
|
||||
0x4CC9F0,
|
||||
0xF72585,
|
||||
0x7BF1A8,
|
||||
0xFFB703,
|
||||
0xB388EB,
|
||||
0xFF7A5C,
|
||||
0x5CE1E6,
|
||||
0xE9FF70,
|
||||
];
|
||||
|
||||
/// <summary>Same player id always gets the same colour, on both server and client.</summary>
|
||||
public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Per-step data handed to every system.</summary>
|
||||
/// <param name="Tick">Index of the step being simulated.</param>
|
||||
/// <param name="DeltaTime">Fixed step length in seconds.</param>
|
||||
/// <param name="Options">Simulation tunables.</param>
|
||||
public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Tunables of the authoritative simulation. Bound from the <c>Simulation</c> config section.</summary>
|
||||
public sealed class SimulationOptions
|
||||
{
|
||||
public const string SectionName = "Simulation";
|
||||
|
||||
/// <summary>Fixed simulation steps per second.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Length of one fixed step.</summary>
|
||||
public float FixedDeltaTime => 1f / TickRate;
|
||||
|
||||
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Integrates velocity into position with the fixed step.</summary>
|
||||
public sealed class MovementSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<Position, Velocity>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
var deltaTime = context.DeltaTime;
|
||||
|
||||
world.Query(in Query, (ref Position position, ref Velocity velocity) =>
|
||||
{
|
||||
position.X += velocity.X * deltaTime;
|
||||
position.Y += velocity.Y * deltaTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Turns the latest button mask of every player into a velocity vector.</summary>
|
||||
public sealed class PlayerInputSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<PlayerControl, Velocity>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
world.Query(in Query, (ref PlayerControl control, ref Velocity velocity) =>
|
||||
{
|
||||
var x = 0f;
|
||||
var y = 0f;
|
||||
|
||||
if ((control.Buttons & InputButtons.Left) != 0) x -= 1f;
|
||||
if ((control.Buttons & InputButtons.Right) != 0) x += 1f;
|
||||
if ((control.Buttons & InputButtons.Up) != 0) y -= 1f;
|
||||
if ((control.Buttons & InputButtons.Down) != 0) y += 1f;
|
||||
|
||||
// Normalize so diagonals are not faster than the cardinal directions.
|
||||
if (x != 0f && y != 0f)
|
||||
{
|
||||
const float InverseSqrt2 = 0.70710678f;
|
||||
x *= InverseSqrt2;
|
||||
y *= InverseSqrt2;
|
||||
}
|
||||
|
||||
velocity.X = x * control.Speed;
|
||||
velocity.Y = y * control.Speed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Keeps every body inside the play field and kills the velocity it pushed with.</summary>
|
||||
public sealed class WorldBoundsSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<Position, Velocity, Renderable>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
var width = context.Options.WorldWidth;
|
||||
var height = context.Options.WorldHeight;
|
||||
|
||||
world.Query(in Query, (ref Position position, ref Velocity velocity, ref Renderable renderable) =>
|
||||
{
|
||||
var minX = renderable.Radius;
|
||||
var maxX = width - renderable.Radius;
|
||||
var minY = renderable.Radius;
|
||||
var maxY = height - renderable.Radius;
|
||||
|
||||
if (position.X < minX)
|
||||
{
|
||||
position.X = minX;
|
||||
velocity.X = 0f;
|
||||
}
|
||||
else if (position.X > maxX)
|
||||
{
|
||||
position.X = maxX;
|
||||
velocity.X = 0f;
|
||||
}
|
||||
|
||||
if (position.Y < minY)
|
||||
{
|
||||
position.Y = minY;
|
||||
velocity.Y = 0f;
|
||||
}
|
||||
else if (position.Y > maxY)
|
||||
{
|
||||
position.Y = maxY;
|
||||
velocity.Y = 0f;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Boots the AppHost once for the whole suite — starting it per test costs about ten
|
||||
/// seconds each. The client resource is skipped (<c>HSchool:Headless</c>), so the tests
|
||||
/// need no Node install.
|
||||
/// </summary>
|
||||
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<Projects.HSchool_AppHost>(["--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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Groups every integration test around the single AppHost instance.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class AppHostCollection : ICollectionFixture<AppHostFixture>
|
||||
{
|
||||
public const string Name = "apphost";
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to the running server exactly the way the browser client does: binary frames over
|
||||
/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
|
||||
/// </summary>
|
||||
[Collection(AppHostCollection.Name)]
|
||||
public class GameServerIntegrationTests(AppHostFixture fixture)
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private DistributedApplication App => fixture.App;
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReportsHealthy()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusEndpoint_ReportsARunningLoop()
|
||||
{
|
||||
using var client = App.CreateHttpClient("server");
|
||||
|
||||
using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var status = JsonSerializer.Deserialize<StatusResponse>(
|
||||
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
|
||||
JsonSerializerOptions.Web);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(20, status.TickRate);
|
||||
Assert.True(status.WorldWidth > 0);
|
||||
|
||||
// The loop runs on its own thread; give it a moment to produce a tick.
|
||||
await WaitUntilAsync(
|
||||
async () => (await GetStatusAsync(client)).Tick > 0,
|
||||
TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_AnswersWithAWelcomeFrame()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
||||
Assert.Equal(20, welcome.TickRate);
|
||||
Assert.True(welcome.PlayerEntityId > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
|
||||
Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Input_MovesThePlayerOnTheServer()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
var welcome = await ReceiveWelcomeAsync(socket);
|
||||
|
||||
var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
|
||||
// Hold "right" for a few ticks, draining snapshots so the socket never backs up.
|
||||
var sequence = 0u;
|
||||
var lastX = startX;
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
|
||||
|
||||
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
|
||||
lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
|
||||
}
|
||||
|
||||
Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_IsAnsweredWithTheSameTimestamp()
|
||||
{
|
||||
using var socket = await ConnectAsync();
|
||||
await ReceiveWelcomeAsync(socket);
|
||||
|
||||
const long ClientTime = 1_700_000_000_123;
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
|
||||
|
||||
var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
|
||||
|
||||
Assert.Equal(ClientTime, ProtocolCodec.ReadPong(pong).ClientTimeMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VersionMismatch_IsRejected()
|
||||
{
|
||||
using var socket = await ConnectRawAsync();
|
||||
|
||||
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
|
||||
buffer,
|
||||
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), "stale-client")));
|
||||
|
||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
|
||||
Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
|
||||
}
|
||||
|
||||
private async Task<ClientWebSocket> ConnectAsync(string playerName = "integration-test")
|
||||
{
|
||||
var socket = await ConnectRawAsync();
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private async Task<ClientWebSocket> ConnectRawAsync()
|
||||
{
|
||||
var http = App.GetEndpoint("server", "http");
|
||||
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
||||
|
||||
var socket = new ClientWebSocket();
|
||||
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static async Task SendAsync(WebSocket socket, Func<byte[], int> write)
|
||||
{
|
||||
var buffer = new byte[64];
|
||||
var length = write(buffer);
|
||||
|
||||
await socket.SendAsync(
|
||||
buffer.AsMemory(0, length),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
|
||||
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
|
||||
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotAsync(WebSocket socket)
|
||||
{
|
||||
var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
|
||||
|
||||
var entities = new EntitySnapshot[ushort.MaxValue];
|
||||
var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
|
||||
|
||||
return entities[..count];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads snapshots until the given entity shows up. The very first snapshot after a join can
|
||||
/// still describe the tick before the spawn was applied.
|
||||
/// </summary>
|
||||
private static async Task<EntitySnapshot[]> ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
|
||||
{
|
||||
for (var attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
var entities = await ReceiveSnapshotAsync(socket);
|
||||
if (Array.Exists(entities, entity => entity.Id == entityId))
|
||||
{
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
|
||||
}
|
||||
|
||||
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
|
||||
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
timeout.CancelAfter(DefaultTimeout);
|
||||
|
||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, timeout.Token);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
|
||||
}
|
||||
|
||||
var frame = buffer[..result.Count];
|
||||
if (ProtocolCodec.PeekMessageType(frame) == expected)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StatusResponse> GetStatusAsync(HttpClient client)
|
||||
{
|
||||
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
|
||||
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Fail($"Condition was not met within {timeout}.");
|
||||
}
|
||||
|
||||
private sealed record StatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.AppHost.Tests</RootNamespace>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\HSchool.AppHost\HSchool.AppHost.csproj" />
|
||||
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="Aspire.Hosting" />
|
||||
<Using Include="Aspire.Hosting.Testing" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Protocol.Tests</RootNamespace>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace HSchool.Protocol.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
|
||||
/// </summary>
|
||||
public class ProtocolCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hello_RoundTrips()
|
||||
{
|
||||
var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
|
||||
Span<byte> 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<byte> buffer = stackalloc byte[16];
|
||||
|
||||
var length = ProtocolCodec.WriteInput(buffer, message);
|
||||
|
||||
Assert.Equal(6, length);
|
||||
Assert.Equal(message, ProtocolCodec.ReadInput(buffer[..length]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ping_RoundTrips()
|
||||
{
|
||||
var message = new ClientPingMessage(1_700_000_000_123);
|
||||
Span<byte> 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<byte> 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<byte> 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<EntitySnapshot> 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<byte> 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<byte> 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<ProtocolException>(() => ProtocolCodec.ReadWelcome(frame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongMessageId_Throws()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[16];
|
||||
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
|
||||
var frame = buffer[..length].ToArray();
|
||||
|
||||
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadInput(frame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedName_Throws()
|
||||
{
|
||||
var message = new ClientHelloMessage(ProtocolConstants.Version, new string('x', 100));
|
||||
var buffer = new byte[256];
|
||||
|
||||
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteHello(buffer, message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UndersizedBuffer_Throws()
|
||||
{
|
||||
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
|
||||
var buffer = new byte[4];
|
||||
|
||||
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteWelcome(buffer, message));
|
||||
}
|
||||
}
|
||||
@@ -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<EntitySnapshot>();
|
||||
world.CaptureSnapshot(buffer);
|
||||
return buffer.Single(entity => entity.Id == networkId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AdvancesTheTickCounter()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
|
||||
world.Tick();
|
||||
world.Tick();
|
||||
|
||||
Assert.Equal(2u, world.CurrentTick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
|
||||
var networkId = world.SpawnPlayer(playerId: 1);
|
||||
|
||||
Assert.Equal(1, world.PlayerCount);
|
||||
Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpawnPlayer_Twice_Throws()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
world.SpawnPlayer(playerId: 1);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => world.SpawnPlayer(playerId: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
|
||||
{
|
||||
var options = Options();
|
||||
using var world = new GameWorld(options);
|
||||
var networkId = world.SpawnPlayer(playerId: 1);
|
||||
var startX = Entity(world, networkId).X;
|
||||
|
||||
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
|
||||
world.Tick();
|
||||
|
||||
var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
|
||||
Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagonalInput_IsNotFasterThanCardinal()
|
||||
{
|
||||
var options = Options();
|
||||
using var world = new GameWorld(options);
|
||||
|
||||
var straight = world.SpawnPlayer(playerId: 1);
|
||||
var diagonal = world.SpawnPlayer(playerId: 2);
|
||||
|
||||
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
|
||||
world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
|
||||
world.Tick();
|
||||
|
||||
var straightBefore = Entity(world, straight);
|
||||
var diagonalBefore = Entity(world, diagonal);
|
||||
world.Tick();
|
||||
|
||||
var straightStep = Distance(straightBefore, Entity(world, straight));
|
||||
var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
|
||||
|
||||
Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Player_StopsAtTheWorldBounds()
|
||||
{
|
||||
var options = Options();
|
||||
using var world = new GameWorld(options);
|
||||
var networkId = world.SpawnPlayer(playerId: 1);
|
||||
|
||||
world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
world.Tick();
|
||||
}
|
||||
|
||||
Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleInput_IsIgnored()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
var networkId = world.SpawnPlayer(playerId: 1);
|
||||
var startX = Entity(world, networkId).X;
|
||||
|
||||
world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
|
||||
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
|
||||
world.Tick();
|
||||
|
||||
Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputForAnUnknownPlayer_IsIgnored()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
|
||||
world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
|
||||
world.Tick();
|
||||
|
||||
Assert.Equal(0, world.PlayerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DespawnPlayer_RemovesItFromSnapshots()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
var networkId = world.SpawnPlayer(playerId: 1);
|
||||
|
||||
world.DespawnPlayer(playerId: 1);
|
||||
|
||||
var buffer = new List<EntitySnapshot>();
|
||||
world.CaptureSnapshot(buffer);
|
||||
|
||||
Assert.Equal(0, world.PlayerCount);
|
||||
Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
|
||||
Assert.Null(world.GetNetworkId(playerId: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DespawnPlayer_Twice_IsHarmless()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
world.SpawnPlayer(playerId: 1);
|
||||
|
||||
world.DespawnPlayer(playerId: 1);
|
||||
world.DespawnPlayer(playerId: 1);
|
||||
|
||||
Assert.Equal(0, world.PlayerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NetworkIds_AreNotRecycledAfterDespawn()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
var first = world.SpawnPlayer(playerId: 1);
|
||||
world.DespawnPlayer(playerId: 1);
|
||||
|
||||
var second = world.SpawnPlayer(playerId: 1);
|
||||
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyWorld_StillContainsTheStaticObstacles()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
|
||||
var buffer = new List<EntitySnapshot>();
|
||||
world.CaptureSnapshot(buffer);
|
||||
|
||||
Assert.NotEmpty(buffer);
|
||||
Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulation_IsDeterministicForTheSameInputs()
|
||||
{
|
||||
var first = Run();
|
||||
var second = Run();
|
||||
|
||||
Assert.Equal(first, second);
|
||||
|
||||
static (float X, float Y) Run()
|
||||
{
|
||||
using var world = new GameWorld(Options());
|
||||
var networkId = world.SpawnPlayer(playerId: 3);
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
|
||||
world.Tick();
|
||||
}
|
||||
|
||||
var entity = Entity(world, networkId);
|
||||
return (entity.X, entity.Y);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsingADisposedWorld_Throws()
|
||||
{
|
||||
var world = new GameWorld(Options());
|
||||
world.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(world.Tick);
|
||||
}
|
||||
|
||||
private static float Distance(EntitySnapshot from, EntitySnapshot to)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
return MathF.Sqrt((dx * dx) + (dy * dy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Simulation.Tests</RootNamespace>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user