221 lines
14 KiB
Markdown
221 lines
14 KiB
Markdown
# 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 |
|
||
| --- | --- |
|
||
| 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/`](docs/design/07-inventory/inventory.md) — slice 7, phases 29–37 |
|
||
| login, who owns a school, watching others | [`docs/design/08-session/`](docs/design/08-session/session.md) — slice 8, phases 38–39 |
|
||
| clock speed buttons and high-speed stride | `src/HSchool.Simulation/ClockSpeed.cs` **and** [`session.md`](docs/design/08-session/session.md) — phase 40 |
|
||
| talks, opinions, fights, romance pack | [`docs/design/09-social/`](docs/design/09-social/social.md) — slice 9, phases 41–47 |
|
||
| lesson consequences (teacher present, skill, warmth, textbook, weather commute) | [`docs/design/off-queue/lesson-consequences.md`](docs/design/off-queue/lesson-consequences.md) — phases 48, 50–53 |
|
||
| layer tests, client tsc-in-test, worker/card splits, dump nodes | [`docs/design/10-craft/`](docs/design/10-craft/craft.md) — slice 10, phases 56–59 |
|
||
| 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/`](docs/phases/off-queue/README.md), 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`](docs/phases/README.md) is a catalog — open only the slice you are changing.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
dotnet build h-school.sln
|
||
```
|
||
|
||
```bash
|
||
dotnet test tests/HSchool.People.Tests --filter FullyQualifiedName~YearlyIntake
|
||
```
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
npm --prefix src/HSchool.Client run build
|
||
```
|
||
|
||
```bash
|
||
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.
|