Files
h-school/AGENTS.md
T

9.0 KiB

Working agreements

Read this before changing anything. It is written for coding agents, and it is just as valid for humans. 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
schools, the game clock, game rules src/HSchool.Simulation
defs, JSONC catalog, map validation src/HSchool.Content
skills, traits, body, needs, name sets src/HSchool.Content
people generation, families, roster records src/HSchool.People
the menu API (list, create, delete, mods, catalog) 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, workers, saves src/HSchool.Server
what runs locally src/HSchool.AppHost/AppHost.cs
screens, dialogs, formatting, UI language src/HSchool.Client/src

Commands

dotnet build h-school.sln
dotnet test
npm --prefix src/HSchool.Client test
npm --prefix src/HSchool.Client run build
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 — 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 a school's worker thread touches that School or its World. The supervisor owns the mailbox table and never calls into a world. Create/delete/name suggestions go through GameCommandQueue; open/close/running/speed go to that school's mailbox; menu reads use the snapshots workers publish; everything outbound goes through the per-client outbox. No locks around a school, 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. 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

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 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 to the server, ui/ renders, format/ formats, i18n/ holds the RU/EN dictionaries, main.ts wires them together.
  • No framework, plain DOM. ui/dom.ts is the whole helper budget.
  • UI strings go through t(...) in i18n/strings.ts, never inline. Game dates go through format/gameTime.ts, which follows the active locale.

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 GameClock or SchoolRegistry test. They are fast and need no host.
  • People generation belongs in tests/HSchool.People.Tests. Same seed, map and name set must produce the same roster; the suite does not boot a host.
  • Catalog, inheritance, patches and map validation belong in tests/HSchool.Content.Tests. Feed the loader documents, not disk paths.
  • Protocol changes need a round-trip test and a byte-layout assertion on both sides.
  • 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 (and now on disk too): start each test by clearing the list (SchoolApiTests.ResetAsync) instead of assuming it is empty. Reset deletes through the API, which deletes the save files. Headless AppHost sets HSchool:AllowSaveReload so tests can POST /api/dev/reload-schools without killing the shared fixture.
  • 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. Dictionaries and date formatting are covered in Vitest (i18n/strings.test.ts, format/gameTime.test.ts).

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, Aspire and the test runners are the whole budget.

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.
  • A modal <dialog> is absolutely positioned with inset: 0, so height: auto stretches it to its max-height instead of shrink-wrapping. .dialog--screen uses height: fit-content for that reason — the map editor sized itself to 800px around a two-room school before it did.
  • 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 each SchoolWorker does, capped at 5 steps — do not "simplify" it away.
  • 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.
  • An abandoned WaitToReadAsync stays queued on its channel. GameClient.RunSendLoopAsync keeps its two wait tasks across iterations for that reason; recreating both on every idle pass leaked the loser of Task.WhenAny once per clock frame.
  • A map snapshot is not bounded by MaxMessageSize. That constant limits what the server reads. Outbound snapshots are sized with ProtocolCodec.MapSnapshotSize, because a school the player enlarged in the create editor passes 8 KiB at roughly sixty furnished rooms.
  • A worker that dies must tell the supervisor (GameCommand.WorkerFailed). It owns the table, so a school that removed itself would break invariant 3 — and a school that removes nothing leaves a card in the menu whose clock never moves again.
  • 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.