Files
h-school/AGENTS.md
T

15 KiB
Raw Blame History

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, countries 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
clothing, inventory, climate, dress codes docs/design/07-inventory/ — slice 7, phases 2937
login, who owns a school, watching others docs/design/08-session/ — slice 8, phases 3839
clock speed buttons and high-speed stride src/HSchool.Simulation/ClockSpeed.cs and session.md — phase 40
talks, opinions, fights, romance pack docs/design/09-social/ — slice 9, phases 4147
lesson consequences (teacher present, skill, warmth, textbook, weather commute) docs/design/off-queue/lesson-consequences.md — phases 48, 5053
layer tests, client tsc-in-test, worker/card splits, dump nodes docs/design/10-craft/ — slice 10, phases 5659
notices, toasts, scene prompt contributions, Krea-only catalog docs/design/11-events/ — slice 11, phases 6367
class teachers, director summons queue, parent meetings docs/design/12-office/ — slice 12, phases 6872
marks 25, attendance journal docs/design/13-grades/ — slice 13, phases 7376
a bug in already-shipped behaviour .claude/skills/bug-work — document in docs/bugs/, discuss only if the fix has a fork, then patch
a task that is not in any slice .claude/skills/side-work — discuss, document in docs/phases/off-queue/, then implement
a new named slice of the game .claude/skills/slice-work — discuss, write design and phases, then /phase-orch

Docs are per slice: docs/phases/<slice>/ and docs/design/<slice>/. docs/phases/README.md is a catalog — open only the slice you are changing.

Commands

dotnet build h-school.sln
dotnet test tests/HSchool.People.Tests --filter FullyQualifiedName~YearlyIntake
npm --prefix src/HSchool.Client test -- src/ui/peoplePanel.test.ts

Client npm test runs tsc -b (typecheck) then vitest; extra args after -- go to vitest. test:watch is vitest only. Solution-wide dotnet test and a full client npm test are CI, or when the user asked. Agents scope the run — Testing policy.

npm --prefix src/HSchool.Client run build
dotnet run --project src/HSchool.AppHost

run-aspire.ps1 is the Windows entry point (run-aspire.cmd just forwards to it); run-aspire.sh is the same on Ubuntu. They skip MSBuild when AppHost and Server dlls are newer than C# / csproj / props. Pass --rebuild to force a build. When tailscale is on PATH and connected they run tailscale serve --bg 5173 and print the HTTPS client URL; otherwise they warn and continue locally only. Pass --no-tailscale to skip Serve. Keep tools/apphost-uptodate.ps1, LaunchBuildStamp, and needs_build in run-aspire.sh in sync if the AppHost path ever moves.

dotnet run --project src/HSchool.AppHost still compiles every time — that is for a dirty tree, not a fast relaunch. It starts the server and the Vite dev server and opens the Aspire dashboard. The Vite client listens on port 5173 (pinned in AppHost for Tailscale Serve).

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

What to run. One project from the list below, filtered to the new or changed class while iterating. Client npm test (typecheck + Vitest) only if src/HSchool.Client changed. HSchool.AppHost.Tests only if the change is HTTP, WebSocket, or host wiring — it boots a server. Do not dotnet test the solution, do not run client and .NET together "to be sure", do not re-run after a merge that only resolved a status line. Solution-wide is CI.

  • Simulation changes need a GameClock or SchoolRegistry test. They are fast and need no host. Putting a roster into World and ticking needs belongs there too.
  • People generation belongs in tests/HSchool.People.Tests. Same seed, map, country 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.
  • Layer bans (Arch, ASP.NET, sockets, wall-clock) belong in tests/HSchool.Architecture.Tests. They do not boot a host.
  • 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.
  • Launch-script up-to-date checks belong in tests/HSchool.AppHost.Tests and must not use AppHostFixture. Keep LaunchBuildStamp and tools/apphost-uptodate.ps1 in sync.
  • Screen logic is covered in Vitest under happy-dom (ui/*.test.ts): people filters and the pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries and date formatting stay in i18n/strings.test.ts and format/gameTime.test.ts. A duplicate key in strings.ts is a typecheck failure (TS1117 / TS2300); client npm test runs tsc before vitest, so it is red without npm run build.

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.
  • A large one-shot Tick(week) jumps the clock, then applies all those minutes at the new time. Presence equality tests must loop FixedDeltaTime or one game minute. string.GetHashCode is randomized — commute slack goes through Seed.Mix, never that.
  • The school seed used to be its id. A test that needs a specific family shape is green on one machine and red on a clean clone. Assert a property, not the first pupil of school 1.
  • Host tests share one server and one save folder. Start each test by clearing the list through the API (SchoolApiTests.ResetAsync).
  • A paused school still emits presence frames. The worker loop ticks the mailbox even when the clock does not advance, so occupancy (often empty) and lesson labels from the timetable keep going out. Opening sends one extra snapshot. A test that waits for people on pause will hang; a test that waits for a lesson label will not.
  • dotnet run on the AppHost always evaluates MSBuild, even when nothing changed. run-aspire.ps1 / run-aspire.sh skip that with --no-build when AppHost and Server dlls are newer than C# / csproj / props. --rebuild forces a build. Bare dotnet run --project src/HSchool.AppHost still compiles.
  • School UI chrome lives in the URL query and sessionStorage, not RAM. A test that assumes GameScreen.show() starts on the map with empty people filters must clear both, or the previous case leaks. F5 reopens ?school=; the server still owns the clock.