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

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
+43 -29
View File
@@ -8,16 +8,17 @@ way; this file is *how to work in them*.
| I want to change… | Go to |
| --- | --- |
| game rules, movement, entities | `src/HSchool.Simulation` |
| what the client receives | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, endpoints | `src/HSchool.Server` |
| schools, the game clock, game rules | `src/HSchool.Simulation` |
| the menu API (list, create, delete) | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, the loop | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| rendering, input, HUD | `src/HSchool.Client/src` |
| screens, dialogs, formatting | `src/HSchool.Client/src` |
## Commands
```bash
dotnet build HSchool.slnx
dotnet build h-school.sln
```
```bash
@@ -53,20 +54,22 @@ These are the rules that keep the base coherent. Breaking one is a design decisi
say so explicitly in the change description.
1. **The server is authoritative.** The client sends intents and draws what it is told. No game
logic in `src/HSchool.Client`.
logic in `src/HSchool.Client` — not even a local clock that ticks between frames.
2. **The protocol lives in three places at once.** `ProtocolCodec.cs`, `protocol.ts` and
`docs/protocol.md` change in the same commit. A layout change bumps
`ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets —
if one of them has to change, so do the other two.
3. **Only the loop thread touches `GameWorld`.** Everything inbound goes through
`GameCommandQueue`; everything outbound goes through the per-client outbox. No locks around the
ECS world, no `Task.Run` into it.
3. **Only the loop thread touches a `School` or the `SchoolRegistry`.** Everything inbound goes
through `GameCommandQueue`; menu reads use the immutable state the loop publishes; everything
outbound goes through the per-client outbox. No locks around the registry, no `Task.Run` into it.
4. **The simulation knows nothing about the network.** `HSchool.Simulation` must not reference
ASP.NET Core, sockets or logging infrastructure. It stays testable without a host.
5. **Fixed timestep.** Systems get `SimulationContext.DeltaTime`, never wall-clock time and never
`DateTime.Now`. Same inputs, same results — `Simulation_IsDeterministicForTheSameInputs` guards it.
6. **Everything from the wire is untrusted.** Validate lengths and ranges in the handler before
anything reaches the simulation.
5. **Fixed timestep.** The clock advances by `SimulationOptions.FixedDeltaTime`, never by
wall-clock deltas and never from `DateTime.Now`. Same tick count, same date.
6. **Everything from the wire is untrusted.** Validate lengths and ranges before anything reaches
the simulation — names, dates and speed indexes all arrive from a browser.
7. **One intent per message.** A frame that also resends a neighbouring field overwrites it with a
stale client copy; that is why running and speed are separate messages.
## Conventions
@@ -76,14 +79,16 @@ say so explicitly in the change description.
- Private fields are `_camelCase`; `.editorconfig` enforces it.
- Nullable is on everywhere. Don't add `!` to silence it; fix the flow.
- Internal by default in `HSchool.Server`; public only where another project consumes it.
- New tunables go on `SimulationOptions` with a default, not as a constant buried in a system.
- New tunables go on `SimulationOptions` with a default, not as a constant buried in a class.
**TypeScript**
- `strict` is on, no `any`, no non-null `!` assertions.
- Relative imports carry the `.ts` extension (bundler resolution is configured for it).
- Modules stay thin: `net/` speaks protocol, `game/` renders, `main.ts` wires them together.
- No framework. If a UI need appears, plain DOM first.
- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `main.ts`
wires them together.
- No framework, plain DOM. `ui/dom.ts` is the whole helper budget.
- UI strings are Russian; game dates are formatted through `format/gameTime.ts`, never inline.
**Both**
@@ -92,13 +97,15 @@ say so explicitly in the change description.
## Testing policy
- Simulation changes need a `GameWorld` test. They are fast, hermetic and do not need a host.
- Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
- Server wiring, endpoints and the WebSocket handshake belong in `tests/HSchool.AppHost.Tests`.
That suite shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting
per test costs about ten seconds each.
- Never assert on the *first* snapshot after a join without checking the entity is in it; the
frame in flight may predate the spawn. Use `ReceiveSnapshotWithAsync`.
- Server wiring, endpoints and the WebSocket belong in `tests/HSchool.AppHost.Tests`. That suite
shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs
about ten seconds each.
- Those tests also share one server, so schools survive between them: start each test by clearing
the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty.
- The screens have no unit tests — a DOM environment would cost a dependency the project does not
have. Verify UI changes by running the app.
## Dependencies
@@ -106,17 +113,24 @@ say so explicitly in the change description.
bare `<PackageReference Include="..." />` in the project.
- Transitive pinning is on, so a downgrade warning means you bump the central version rather than
adding a per-project override.
- Keep the dependency count low. Arch, PixiJS, Aspire and the test runners are the whole budget;
anything new needs a reason in the change description.
- Keep the dependency count low. Arch, Aspire and the test runners are the whole budget; `pixi.js`
is installed but not imported yet — it is there for the game view inside a school.
## Things that will bite you
- **`<dialog>`'s `close` event is not delivered by every engine** (Chromium 148 fires only
`toggle`). `ui/modal.ts` resolves its promise explicitly on every exit for that reason — never go
back to awaiting the event, or a dialog will silently freeze the screen that awaited it.
- **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration
arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own
time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason.
- `PeriodicTimer` does not catch up on its own. The accumulator in `GameLoopService` does, capped
at 5 steps — do not "simplify" it away.
- Arch recycles entity ids. Replicate `NetworkId`, never `Entity.Id`.
- Snapshots are full-state: an entity missing from a frame is *despawned* by the client. Filtering
entities out of a snapshot is how you accidentally delete them on screen.
- The client outbox drops the oldest frame under pressure. That is correct for snapshots and wrong
for anything that must arrive exactly once — such a message would need its own path.
- Every school runs whether or not a connection is watching it, so anything you hang off the tick
runs six times over once six schools exist. `OpenSchool` only subscribes to clock frames.
- The menu polls `GET /api/schools` once a second and patches its cards in place. Rebuilding the
list on every refresh would drop focus and swallow clicks.
- The client outbox drops the oldest frame under pressure. That is correct for clock frames and
wrong for anything that must arrive exactly once — such a message would need its own path.
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
are used throughout.