- Revised `AGENTS.md` to specify that the same seed, map, name set, and native language must produce the same roster in tests. - Updated `protocol.md` to clarify that everyone in the school is generated with the selected native language, and related languages may appear at a low skill. - Enhanced `ai.md` to explain skill growth mechanics based on state and the introduction of minimum ranges for skills not yet acquired. - Improved `people.md` to detail the generation of skills and traits, emphasizing that not every person receives every skill and the implications of body attributes on skill acquisition. - Adjusted `projects.md` to reflect the inclusion of native language in roster generation, ensuring comprehensive documentation of project components.
9.9 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, yearly intake | src/HSchool.People |
| timetable planning | src/HSchool.Schedule |
| routes, day plans, who comes today | src/HSchool.Ai |
| people in a school's World, need decay, walking | src/HSchool.Simulation |
| the menu API (list, create, delete, mods, catalog) and the people list/card | 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.
- 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. - The protocol lives in three places at once.
ProtocolCodec.cs,protocol.tsanddocs/protocol.mdchange in the same commit. A layout change bumpsProtocolConstants.Version/PROTOCOL_VERSION. Tests on both sides assert byte offsets — if one of them has to change, so do the other two. - Only a school's worker thread touches that
Schoolor itsWorld. The supervisor owns the mailbox table and never calls into a world. Create/delete/name suggestions go throughGameCommandQueue; 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, noTask.Runinto it. - The simulation knows nothing about the network.
HSchool.Simulationmust not reference ASP.NET Core, sockets or logging infrastructure. It stays testable without a host. - Fixed timestep. The clock advances by
SimulationOptions.FixedDeltaTime, never by wall-clock deltas and never fromDateTime.Now. Same tick count, same date. - 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.
- 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,
varwhere the type is obvious, primary constructors for services. - Private fields are
_camelCase;.editorconfigenforces 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
SimulationOptionswith a default, not as a constant buried in a class.
TypeScript
strictis on, noany, no non-null!assertions.- Relative imports carry the
.tsextension (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.tswires them together. - No framework, plain DOM.
ui/dom.tsis the whole helper budget. - UI strings go through
t(...)ini18n/strings.ts, never inline. Game dates go throughformat/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
GameClockorSchoolRegistrytest. They are fast and need no host. Putting a roster intoWorldand ticking needs belongs there too. - People generation belongs in
tests/HSchool.People.Tests. Same seed, map, name set and native language must produce the same roster; the suite does not boot a host. - Timetable planning belongs in
tests/HSchool.Schedule.Tests. Same staff, map and locks must produce the same table; the suite does not boot a host. - Walking and day plans belong in
tests/HSchool.Ai.Tests. Same seed and map must produce the same route and commute; the suite does not boot a host, and it does not reference Arch. - 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 setsHSchool:AllowSaveReloadso tests canPOST /api/dev/reload-schoolswithout 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>'scloseevent is not delivered by every engine (Chromium 148 fires onlytoggle).ui/modal.tsresolves 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 withinset: 0, soheight: autostretches it to itsmax-heightinstead of shrink-wrapping..dialog--screenusesheight: fit-contentfor 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
DateTimebound from configuration arrives asKind=Unspecified, serializes without aZ, and the browser then reads it in its own time zone —SimulationOptions.DefaultStartDateforces the kind for exactly that reason. PeriodicTimerdoes not catch up on its own. The accumulator in eachSchoolWorkerdoes, 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.
OpenSchoolonly subscribes to clock frames. - The menu polls
GET /api/schoolsonce a second and patches its cards in place. Rebuilding the list on every refresh would drop focus and swallow clicks. - An abandoned
WaitToReadAsyncstays queued on its channel.GameClient.RunSendLoopAsynckeeps its two wait tasks across iterations for that reason; recreating both on every idle pass leaked the loser ofTask.WhenAnyonce per clock frame. - A map snapshot is not bounded by
MaxMessageSize. That constant limits what the server reads. Outbound snapshots are sized withProtocolCodec.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.
erasableSyntaxOnlyis off intsconfig.app.jsonon purpose: constructor parameter properties are used throughout.- A large one-shot
Tick(week)jumps the clock, then applies all those minutes at the new time. Presence equality tests must loopFixedDeltaTimeor one game minute.string.GetHashCodeis randomized — commute slack goes throughSeed.Mix, never that.