diff --git a/AGENTS.md b/AGENTS.md index 10236ce..5352566 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ way; this file is *how to work in them*. | I want to change… | Go to | | --- | --- | | schools, the game clock, game rules | `src/HSchool.Simulation` | +| defs, JSONC catalog, map validation | `src/HSchool.Content` | | 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, workers, saves | `src/HSchool.Server` | @@ -101,6 +102,8 @@ say so explicitly in the change description. ## Testing policy - Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no 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 diff --git a/README.md b/README.md index 5885d64..2368866 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ dotnet test ``` - `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts. +- `tests/HSchool.Content.Tests` — JSONC catalog, inheritance, patches, map connectivity. No host. - `tests/HSchool.Simulation.Tests` — the game clock, school load/create, and the school registry, no host involved. - `tests/HSchool.AppHost.Tests` — boots the real Aspire graph, drives the menu API and the WebSocket clock. Runs headless (`--HSchool:Headless=true`), so no Node install is needed. @@ -89,8 +90,10 @@ Vitest covers the client codec, the calendar formatting and the RU/EN dictionari ``` src/ HSchool.Protocol/ binary wire format (shared contract with the client) + HSchool.Content/ JSONC defs, patches, map validation HSchool.Simulation/ schools, the game clock, the Arch ECS world - HSchool.Server/ ASP.NET Core host, menu API, WebSocket endpoint, game loop + HSchool.Server/ ASP.NET Core host, menu API, WebSocket, workers + HSchool.Server/mods/ pack folders; `core` is always on HSchool.ServiceDefaults/ Aspire telemetry, health checks, resilience HSchool.AppHost/ Aspire orchestration HSchool.Client/ Vite + TypeScript UI @@ -112,9 +115,12 @@ Simulation tunables live under the `Simulation` section of | `MaxSchools` | 6 | how many schools may exist at once | | `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 | | `DefaultStartDate` | `2012-04-03T06:00:00` | prefilled start of a new school | +| `SavesDirectory` | `saves` | per-school JSON files | +| `ModsDirectory` | `mods` | pack folders; `core` is required | +| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | ## What is deliberately missing -No persistence, no authentication, and nothing inside a school yet — every school owns an empty -ECS world waiting for its first entities. Each of these has a seam described in +No authentication, no action execution, and the create dialog does not yet pick mods or edit the +map — every new school gets the vanilla `core` layout. Each of these has a seam described in [`docs/architecture.md`](docs/architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md index 4fab4fb..6082c21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,8 +14,11 @@ there is no UI on the server. │ │ ├── SchoolWorker ×N │ └──────────────────┘ │ │ │ │ └── School │ │ │ │ │ ├─ Clock│ │ +│ │ │ ├─ Catalog (frozen Content) │ +│ │ │ ├─ Map │ │ │ │ └─ World│ (Arch ECS, empty for now) │ │ │ ├── SchoolStore │ saves/{id}.json │ +│ │ ├── ModContent │ mods// │ │ │ └── ClientRegistry │ │ │ └────────────────────────┘ │ │ │ OTLP logs / traces / metrics │ @@ -29,14 +32,15 @@ there is no UI on the server. | Project | Role | | --- | --- | | `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. | -| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. No ASP.NET, no sockets — this is what unit tests exercise. | -| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, disk saves. | +| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. | +| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. | +| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. | | `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. | | `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. | | `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. | -Dependency direction is one-way: `Protocol ← Server → Simulation`. Nothing in `Simulation` knows -about HTTP, and nothing in `Protocol` knows about schools. +Dependency direction is one-way: `Protocol ← Server → Simulation → Content`. Nothing in +`Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools. ## Two channels, on purpose @@ -94,8 +98,9 @@ cannot land between a mouse-down and a click. Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json` for the next id). The worker writes on create, pause, speed change, shutdown, and on a rare clock -snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The supervisor reloads the -directory at process start. A file that cannot be read is left in place and logged. +snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The file also stores the +mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing +mod folder or a map that no longer validates leaves the file in place and that school unstarted. ## Connection lifetime @@ -115,6 +120,6 @@ that cannot keep up loses intermediate clock frames instead of stalling a worker `School.Tick`, and unit-test them against `School` directly — no server needed. - **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from the server after every change, so nothing else has to know. -- **Defs, map, mods**: the save record is intentionally small so later slices can add layout and - pack ids without a second persistence mechanism. That work lives in - [`phases/03-defs-map.md`](phases/03-defs-map.md). +- **Create editor and the map snapshot**: the catalog and vanilla map exist; the player still + cannot pick mods or see the tree from the server. That work lives in + [`phases/04-create-editor.md`](phases/04-create-editor.md). diff --git a/docs/design/projects.md b/docs/design/projects.md index f78ad19..6b35acf 100644 --- a/docs/design/projects.md +++ b/docs/design/projects.md @@ -1,8 +1,7 @@ # Нарезка проектов (целевая) -Сейчас в коде: `Protocol ← Server → Simulation`. Этого мало, когда появятся defs, карта и моды. -Класть каталог в Simulation (рядом с Arch) или в Server (рядом с Kestrel) — оба варианта смешают -слои. Ниже — куда что идёт в этом срезе. `architecture.md` правится, когда код так и станет. +Сейчас в коде: `Protocol ← Server → Simulation → Content`. Каталог не в Simulation (рядом с Arch) +и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick. ## Зависимости diff --git a/docs/phases/03-defs-map.md b/docs/phases/03-defs-map.md index 248a976..7e1f5bd 100644 --- a/docs/phases/03-defs-map.md +++ b/docs/phases/03-defs-map.md @@ -10,19 +10,19 @@ ## Задачи -- [ ] Проект `HSchool.Content` (+ тесты): JSONC, defs, локали, граф карты, валидация. Не Arch, не ASP.NET -- [ ] Загрузчик пачки документов (сервер потом подставит папки `mods/`) -- [ ] `core` всегда первый; дальше моды по списку; повтор `defName` / ключа локали — последний победил, warning в лог -- [ ] Резолв ссылок `ThingDef.actions`, слотов RoomDef после чтения всего набора -- [ ] Карта-инстанс: двор (TerritoryDef) всегда есть; здания (BuildingDef), этажи (FloorDef + id), помещения, двусторонние рёбра -- [ ] Валидация: неизвестный def, ребро в никуда, несвязный граф, изолированный узел -- [ ] Пустая комната допустима; должности для панели — из def узла, если они там есть -- [ ] Каталог замораживается на работнике при create/load; сейв хранит id модов и раскладку, не развёрнутый каталог -- [ ] Нет папки мода из сейва — школу не стартовать, файл не удалять, в лог -- [ ] Обновить ориентир в `AGENTS.md` (куда класть defs) -- [ ] Наследование: `parent`, `abstract`, замена полей, запрет цикла и чужого вида, abstract нельзя на карту -- [ ] Патчи из `patches/`: add / replace / remove по JSON Pointer; неизвестный op или нет target — ошибка каталога -- [ ] Тесты Content: фикстуры JSONC, last-wins, parent/abstract, патч add в actions, связность, пустая комната, одна комната без двора — отказ +- [x] Проект `HSchool.Content` (+ тесты): JSONC, defs, локали, граф карты, валидация. Не Arch, не ASP.NET +- [x] Загрузчик пачки документов (сервер потом подставит папки `mods/`) +- [x] `core` всегда первый; дальше моды по списку; повтор `defName` / ключа локали — последний победил, warning в лог +- [x] Резолв ссылок `ThingDef.actions`, слотов RoomDef после чтения всего набора +- [x] Карта-инстанс: двор (TerritoryDef) всегда есть; здания (BuildingDef), этажи (FloorDef + id), помещения, двусторонние рёбра +- [x] Валидация: неизвестный def, ребро в никуда, несвязный граф, изолированный узел +- [x] Пустая комната допустима; должности для панели — из def узла, если они там есть +- [x] Каталог замораживается на работнике при create/load; сейв хранит id модов и раскладку, не развёрнутый каталог +- [x] Нет папки мода из сейва — школу не стартовать, файл не удалять, в лог +- [x] Обновить ориентир в `AGENTS.md` (куда класть defs) +- [x] Наследование: `parent`, `abstract`, замена полей, запрет цикла и чужого вида, abstract нельзя на карту +- [x] Патчи из `patches/`: add / replace / remove по JSON Pointer; неизвестный op или нет target — ошибка каталога +- [x] Тесты Content: фикстуры JSONC, last-wins, parent/abstract, патч add в actions, связность, пустая комната, одна комната без двора — отказ ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 0e1b908..5493c12 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -14,5 +14,5 @@ | [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется | | [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных | | [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа | -| [3. Каталог def и карта](03-defs-map.md) | ⬜ | JSONC, core, валидация раскладки | +| [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки | | [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии | diff --git a/h-school.sln b/h-school.sln index 47b8d20..54c64fe 100644 --- a/h-school.sln +++ b/h-school.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 @@ -22,44 +23,140 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol.Tests", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation.Tests", "tests\HSchool.Simulation.Tests\HSchool.Simulation.Tests.csproj", "{25518ACB-AC00-4DE7-7F61-2756A5F47A38}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Content", "src\HSchool.Content\HSchool.Content.csproj", "{1C464147-8717-46FD-8459-9F458A07836D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Content.Tests", "tests\HSchool.Content.Tests\HSchool.Content.Tests.csproj", "{C59BD649-ED81-40EE-864D-9C7D5378B956}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|x64.Build.0 = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|x86.Build.0 = Debug|Any CPU {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.Build.0 = Release|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|x64.ActiveCfg = Release|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|x64.Build.0 = Release|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|x86.ActiveCfg = Release|Any CPU + {C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|x86.Build.0 = Release|Any CPU {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|x64.Build.0 = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|x86.Build.0 = Debug|Any CPU {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.Build.0 = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|x64.ActiveCfg = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|x64.Build.0 = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|x86.ActiveCfg = Release|Any CPU + {E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|x86.Build.0 = Release|Any CPU {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|x64.ActiveCfg = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|x64.Build.0 = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|x86.Build.0 = Debug|Any CPU {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.ActiveCfg = Release|Any CPU {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.Build.0 = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|x64.ActiveCfg = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|x64.Build.0 = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|x86.ActiveCfg = Release|Any CPU + {6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|x86.Build.0 = Release|Any CPU {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|x64.Build.0 = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|x86.Build.0 = Debug|Any CPU {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.Build.0 = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|x64.ActiveCfg = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|x64.Build.0 = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|x86.ActiveCfg = Release|Any CPU + {D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|x86.Build.0 = Release|Any CPU {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|x64.Build.0 = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|x86.ActiveCfg = Debug|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|x86.Build.0 = Debug|Any CPU {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.Build.0 = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|x64.ActiveCfg = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|x64.Build.0 = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|x86.ActiveCfg = Release|Any CPU + {06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|x86.Build.0 = Release|Any CPU {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|x64.Build.0 = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|x86.Build.0 = Debug|Any CPU {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.Build.0 = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|x64.ActiveCfg = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|x64.Build.0 = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|x86.ActiveCfg = Release|Any CPU + {5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|x86.Build.0 = Release|Any CPU {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|x64.Build.0 = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|x86.Build.0 = Debug|Any CPU {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.ActiveCfg = Release|Any CPU {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.Build.0 = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|x64.ActiveCfg = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|x64.Build.0 = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|x86.ActiveCfg = Release|Any CPU + {962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|x86.Build.0 = Release|Any CPU {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|x64.ActiveCfg = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|x64.Build.0 = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|x86.ActiveCfg = Debug|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|x86.Build.0 = Debug|Any CPU {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.ActiveCfg = Release|Any CPU {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.Build.0 = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|x64.ActiveCfg = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|x64.Build.0 = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|x86.ActiveCfg = Release|Any CPU + {25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|x86.Build.0 = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|x64.ActiveCfg = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|x64.Build.0 = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Debug|x86.Build.0 = Debug|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|Any CPU.Build.0 = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|x64.ActiveCfg = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|x64.Build.0 = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|x86.ActiveCfg = Release|Any CPU + {1C464147-8717-46FD-8459-9F458A07836D}.Release|x86.Build.0 = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|x64.ActiveCfg = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|x64.Build.0 = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|x86.ActiveCfg = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Debug|x86.Build.0 = Debug|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|Any CPU.Build.0 = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x64.ActiveCfg = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x64.Build.0 = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x86.ActiveCfg = Release|Any CPU + {C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,6 +170,8 @@ Global {5F583583-FF9A-2935-F4EF-D2CDD8DFC465} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {962E7F03-8B12-5802-91AA-105EEC2060E4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {25518ACB-AC00-4DE7-7F61-2756A5F47A38} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {1C464147-8717-46FD-8459-9F458A07836D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829} diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs new file mode 100644 index 0000000..b4bd9af --- /dev/null +++ b/src/HSchool.Content/CatalogLoader.cs @@ -0,0 +1,393 @@ +using System.Text.Json.Nodes; + +namespace HSchool.Content; + +/// +/// Turns pack documents into a frozen . Callers supply already-read +/// files; this type never looks at the disk. +/// +public sealed class CatalogLoader +{ + public const string CorePackId = "core"; + + public DefCatalog Load( + IReadOnlyList packOrder, + IReadOnlyList documents, + IContentLog? log = null) + { + log ??= NullContentLog.Instance; + var order = NormalizePackOrder(packOrder); + + var defs = new Dictionary<(DefKind Kind, string Name), RawDef>(); + var localesRu = new Dictionary(StringComparer.Ordinal); + var localesEn = new Dictionary(StringComparer.Ordinal); + var patches = new List<(string PackId, PatchDocument Patch, string Source)>(); + + foreach (var packId in order) + { + foreach (var document in documents.Where(candidate => candidate.PackId == packId)) + { + ReadDocument(document, defs, localesRu, localesEn, patches, log); + } + } + + var resolved = ResolveInheritance(defs); + ApplyPatches(resolved, patches); + var catalog = Materialize(order, resolved, localesRu, localesEn); + ResolveReferences(catalog); + return catalog; + } + + public static IReadOnlyList NormalizePackOrder(IReadOnlyList packOrder) + { + var order = new List { CorePackId }; + foreach (var packId in packOrder) + { + if (string.IsNullOrWhiteSpace(packId) || packId.Equals(CorePackId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!order.Contains(packId, StringComparer.OrdinalIgnoreCase)) + { + order.Add(packId); + } + } + + return order; + } + + public static MapLayout? LastDefaultMap(IReadOnlyList packOrder, IReadOnlyList documents) + { + var order = NormalizePackOrder(packOrder); + MapLayout? map = null; + foreach (var packId in order) + { + foreach (var document in documents.Where(candidate => candidate.PackId == packId && PackPaths.IsDefaultMap(candidate.RelativePath))) + { + map = MapLayout.Parse(document.Text, $"{packId}:{document.RelativePath}"); + } + } + + return map; + } + + private static void ReadDocument( + ContentDocument document, + Dictionary<(DefKind Kind, string Name), RawDef> defs, + Dictionary localesRu, + Dictionary localesEn, + List<(string PackId, PatchDocument Patch, string Source)> patches, + IContentLog log) + { + var source = $"{document.PackId}:{document.RelativePath}"; + + if (PackPaths.TryGetDefKind(document.RelativePath, out var kind)) + { + foreach (var json in EnumerateObjects(document.Text, source)) + { + var defName = ReadDefName(json, source); + var key = (kind, defName); + if (defs.TryGetValue(key, out var previous)) + { + log.Warning($"Def {kind}:{defName} from pack '{document.PackId}' replaces '{previous.PackId}'."); + } + + defs[key] = new RawDef(document.PackId, kind, defName, json, source); + } + + return; + } + + if (PackPaths.IsPatch(document.RelativePath)) + { + foreach (var json in EnumerateObjects(document.Text, source)) + { + var patch = Jsonc.Deserialize(json); + if (string.IsNullOrWhiteSpace(patch.Target)) + { + throw new ContentLoadException($"Patch in {source} has no target."); + } + + patches.Add((document.PackId, patch, source)); + } + + return; + } + + if (PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language)) + { + var table = language == "en" ? localesEn : localesRu; + MergeLocale(Jsonc.Parse(document.Text, source), table, document.PackId, log); + } + } + + private static IEnumerable EnumerateObjects(string text, string source) + { + var node = Jsonc.Parse(text, source); + switch (node) + { + case JsonObject obj: + yield return obj; + break; + + case JsonArray array: + foreach (var item in array) + { + if (item is not JsonObject obj) + { + throw new ContentLoadException($"{source} has a non-object entry in an array."); + } + + yield return obj; + } + + break; + + default: + throw new ContentLoadException($"{source} must be an object or an array of objects."); + } + } + + private static string ReadDefName(JsonObject json, string source) + { + if (json["defName"] is JsonValue value && value.TryGetValue(out var name) && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + throw new ContentLoadException($"A def in {source} is missing defName."); + } + + private static void MergeLocale(JsonNode node, Dictionary table, string packId, IContentLog log) + { + if (node is not JsonObject obj) + { + throw new ContentLoadException($"Localization in pack '{packId}' must be an object of strings."); + } + + foreach (var property in obj) + { + if (property.Value is not JsonValue value || !value.TryGetValue(out var text)) + { + throw new ContentLoadException($"Localization key '{property.Key}' in pack '{packId}' is not a string."); + } + + if (table.ContainsKey(property.Key)) + { + log.Warning($"Locale key '{property.Key}' from pack '{packId}' replaces an earlier pack."); + } + + table[property.Key] = text; + } + } + + private static Dictionary<(DefKind Kind, string Name), JsonObject> ResolveInheritance( + Dictionary<(DefKind Kind, string Name), RawDef> defs) + { + var resolved = new Dictionary<(DefKind Kind, string Name), JsonObject>(); + var visiting = new HashSet<(DefKind Kind, string Name)>(); + + foreach (var key in defs.Keys) + { + ResolveOne(key, defs, resolved, visiting); + } + + return resolved; + } + + private static JsonObject ResolveOne( + (DefKind Kind, string Name) key, + Dictionary<(DefKind Kind, string Name), RawDef> defs, + Dictionary<(DefKind Kind, string Name), JsonObject> resolved, + HashSet<(DefKind Kind, string Name)> visiting) + { + if (resolved.TryGetValue(key, out var already)) + { + return already; + } + + if (!defs.TryGetValue(key, out var raw)) + { + throw new ContentLoadException($"Def {key.Kind}:{key.Name} was referenced as a parent but does not exist."); + } + + if (!visiting.Add(key)) + { + throw new ContentLoadException($"Def {key.Kind}:{key.Name} has a cyclic parent chain."); + } + + JsonObject merged; + var parentName = raw.Json["parent"] is JsonValue parentValue && parentValue.TryGetValue(out var name) + ? name + : null; + + if (string.IsNullOrWhiteSpace(parentName)) + { + merged = (JsonObject)raw.Json.DeepClone(); + } + else + { + var parentCandidates = defs.Keys.Where(candidate => candidate.Name.Equals(parentName, StringComparison.Ordinal)).ToList(); + if (parentCandidates.Count == 0) + { + throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} parent '{parentName}' does not exist."); + } + + if (!parentCandidates.Any(candidate => candidate.Kind == raw.Kind)) + { + throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} cannot inherit from a different kind '{parentName}'."); + } + + var parentJson = ResolveOne((raw.Kind, parentName), defs, resolved, visiting); + merged = Merge(parentJson, raw.Json); + } + + visiting.Remove(key); + resolved[key] = merged; + return merged; + } + + /// + /// Child fields replace parent fields wholesale, including arrays. + /// abstract is a flag of this def, not inherited: a child of an abstract parent is + /// concrete unless it also says abstract: true. + /// + private static JsonObject Merge(JsonObject parent, JsonObject child) + { + var result = (JsonObject)parent.DeepClone(); + foreach (var property in child) + { + result[property.Key] = property.Value?.DeepClone(); + } + + if (!child.ContainsKey("abstract")) + { + result.Remove("abstract"); + } + + return result; + } + + private static void ApplyPatches( + Dictionary<(DefKind Kind, string Name), JsonObject> resolved, + List<(string PackId, PatchDocument Patch, string Source)> patches) + { + foreach (var (_, patch, source) in patches) + { + var matches = resolved.Where(pair => pair.Key.Name.Equals(patch.Target, StringComparison.Ordinal)).ToList(); + if (matches.Count == 0) + { + throw new ContentLoadException($"Patch target '{patch.Target}' was not found ({source})."); + } + + foreach (var match in matches) + { + PatchApplier.Apply(match.Value, patch); + } + } + } + + private static DefCatalog Materialize( + IReadOnlyList packIds, + Dictionary<(DefKind Kind, string Name), JsonObject> resolved, + Dictionary ru, + Dictionary en) + { + var actions = new Dictionary(StringComparer.Ordinal); + var things = new Dictionary(StringComparer.Ordinal); + var positions = new Dictionary(StringComparer.Ordinal); + var works = new Dictionary(StringComparer.Ordinal); + var rooms = new Dictionary(StringComparer.Ordinal); + var buildings = new Dictionary(StringComparer.Ordinal); + var floors = new Dictionary(StringComparer.Ordinal); + var territories = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, json) in resolved) + { + switch (key.Kind) + { + case DefKind.Action: + actions[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Thing: + things[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Position: + positions[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Work: + works[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Room: + rooms[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Building: + buildings[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Floor: + floors[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Territory: + territories[key.Name] = Jsonc.Deserialize(json); + break; + } + } + + return new DefCatalog( + packIds, + actions, + things, + positions, + works, + rooms, + buildings, + floors, + territories, + ru, + en); + } + + private static void ResolveReferences(DefCatalog catalog) + { + foreach (var thing in catalog.Things.Values) + { + foreach (var action in thing.Actions) + { + if (!catalog.Actions.ContainsKey(action)) + { + throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ActionDef '{action}'."); + } + } + } + + foreach (var room in catalog.Rooms.Values) + { + foreach (var slot in room.Slots) + { + if (!catalog.Things.ContainsKey(slot.Thing)) + { + throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' references unknown ThingDef '{slot.Thing}'."); + } + } + + foreach (var position in room.Positions) + { + if (!catalog.Positions.ContainsKey(position)) + { + throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown PositionDef '{position}'."); + } + } + + foreach (var work in room.Works) + { + if (!catalog.Works.ContainsKey(work)) + { + throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'."); + } + } + } + } + + private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source); +} diff --git a/src/HSchool.Content/ContentDocument.cs b/src/HSchool.Content/ContentDocument.cs new file mode 100644 index 0000000..7762406 --- /dev/null +++ b/src/HSchool.Content/ContentDocument.cs @@ -0,0 +1,4 @@ +namespace HSchool.Content; + +/// A JSONC file from one pack. The server (or a test) already read the bytes. +public sealed record ContentDocument(string PackId, string RelativePath, string Text); diff --git a/src/HSchool.Content/ContentExceptions.cs b/src/HSchool.Content/ContentExceptions.cs new file mode 100644 index 0000000..e0611ac --- /dev/null +++ b/src/HSchool.Content/ContentExceptions.cs @@ -0,0 +1,21 @@ +namespace HSchool.Content; + +/// A pack failed to become a catalog. The school that asked for it must not start. +public sealed class ContentLoadException : Exception +{ + public ContentLoadException(string message) : base(message) + { + } + + public ContentLoadException(string message, Exception innerException) : base(message, innerException) + { + } +} + +/// A map instance does not match its catalog or is not a connected yard-rooted graph. +public sealed class MapValidationException : Exception +{ + public MapValidationException(string message) : base(message) + { + } +} diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs new file mode 100644 index 0000000..0173cec --- /dev/null +++ b/src/HSchool.Content/DefCatalog.cs @@ -0,0 +1,120 @@ +namespace HSchool.Content; + +/// +/// Frozen set of defs and locale strings for one school. Built once at create/load; the worker +/// never re-reads pack files afterwards. +/// +public sealed class DefCatalog +{ + internal DefCatalog( + IReadOnlyList packIds, + IReadOnlyDictionary actions, + IReadOnlyDictionary things, + IReadOnlyDictionary positions, + IReadOnlyDictionary works, + IReadOnlyDictionary rooms, + IReadOnlyDictionary buildings, + IReadOnlyDictionary floors, + IReadOnlyDictionary territories, + IReadOnlyDictionary ru, + IReadOnlyDictionary en) + { + PackIds = packIds; + Actions = actions; + Things = things; + Positions = positions; + Works = works; + Rooms = rooms; + Buildings = buildings; + Floors = floors; + Territories = territories; + _ru = ru; + _en = en; + } + + public IReadOnlyList PackIds { get; } + + public IReadOnlyDictionary Actions { get; } + + public IReadOnlyDictionary Things { get; } + + public IReadOnlyDictionary Positions { get; } + + public IReadOnlyDictionary Works { get; } + + public IReadOnlyDictionary Rooms { get; } + + public IReadOnlyDictionary Buildings { get; } + + public IReadOnlyDictionary Floors { get; } + + public IReadOnlyDictionary Territories { get; } + + private readonly IReadOnlyDictionary _ru; + private readonly IReadOnlyDictionary _en; + + public bool TryGet(DefKind kind, string defName, out Def def) + { + Def? found = kind switch + { + DefKind.Action => Actions.GetValueOrDefault(defName), + DefKind.Thing => Things.GetValueOrDefault(defName), + DefKind.Position => Positions.GetValueOrDefault(defName), + DefKind.Work => Works.GetValueOrDefault(defName), + DefKind.Room => Rooms.GetValueOrDefault(defName), + DefKind.Building => Buildings.GetValueOrDefault(defName), + DefKind.Floor => Floors.GetValueOrDefault(defName), + DefKind.Territory => Territories.GetValueOrDefault(defName), + _ => null, + }; + + if (found is null) + { + def = null!; + return false; + } + + def = found; + return true; + } + + /// + /// Positions the location panel should list for this node type. Only RoomDefs carry them today. + /// + public IReadOnlyList PositionsFor(DefKind kind, string defName) => + kind == DefKind.Room && Rooms.TryGetValue(defName, out var room) ? room.Positions : []; + + /// Locale string for a def, walking parent when the key is missing. Falls back to defName. + public string Label(string locale, Def def) + { + var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru; + var current = def; + while (true) + { + if (table.TryGetValue(current.DefName, out var label)) + { + return label; + } + + if (current.Parent is null || !TryGet(KindOf(current), current.Parent, out var parent) || parent.DefName == current.DefName) + { + return def.DefName; + } + + current = parent; + } + } + + internal static DefKind KindOf(Def def) => def switch + { + ActionDef => DefKind.Action, + ThingDef => DefKind.Thing, + PositionDef => DefKind.Position, + WorkDef => DefKind.Work, + RoomDef => DefKind.Room, + BuildingDef => DefKind.Building, + FloorDef => DefKind.Floor, + TerritoryDef => DefKind.Territory, + _ => throw new ArgumentOutOfRangeException(nameof(def)), + }; +} diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs new file mode 100644 index 0000000..b00286d --- /dev/null +++ b/src/HSchool.Content/Defs.cs @@ -0,0 +1,58 @@ +namespace HSchool.Content; + +public enum DefKind +{ + Action, + Thing, + Position, + Work, + Room, + Building, + Floor, + Territory, +} + +/// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. +public abstract class Def +{ + public required string DefName { get; init; } + + public string? Parent { get; init; } + + public bool Abstract { get; init; } +} + +public sealed class ActionDef : Def; + +public sealed class ThingDef : Def +{ + public IReadOnlyList Actions { get; init; } = []; +} + +public sealed class PositionDef : Def; + +public sealed class WorkDef : Def; + +public sealed class RoomSlot +{ + public required string Key { get; init; } + + public required string Thing { get; init; } + + public int Count { get; init; } = 1; +} + +public sealed class RoomDef : Def +{ + public IReadOnlyList Slots { get; init; } = []; + + public IReadOnlyList Positions { get; init; } = []; + + public IReadOnlyList Works { get; init; } = []; +} + +public sealed class BuildingDef : Def; + +public sealed class FloorDef : Def; + +public sealed class TerritoryDef : Def; diff --git a/src/HSchool.Content/HSchool.Content.csproj b/src/HSchool.Content/HSchool.Content.csproj new file mode 100644 index 0000000..67dd2b8 --- /dev/null +++ b/src/HSchool.Content/HSchool.Content.csproj @@ -0,0 +1,7 @@ + + + + HSchool.Content + + + diff --git a/src/HSchool.Content/IContentLog.cs b/src/HSchool.Content/IContentLog.cs new file mode 100644 index 0000000..44d6fa7 --- /dev/null +++ b/src/HSchool.Content/IContentLog.cs @@ -0,0 +1,17 @@ +namespace HSchool.Content; + +/// Warnings from the loader — last-wins collisions, not fatal errors. +public interface IContentLog +{ + void Warning(string message); +} + +/// Drops warnings. Tests that do not care about collisions use this. +public sealed class NullContentLog : IContentLog +{ + public static NullContentLog Instance { get; } = new(); + + public void Warning(string message) + { + } +} diff --git a/src/HSchool.Content/JsonPointer.cs b/src/HSchool.Content/JsonPointer.cs new file mode 100644 index 0000000..6ea5b91 --- /dev/null +++ b/src/HSchool.Content/JsonPointer.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Nodes; + +namespace HSchool.Content; + +/// RFC 6901 pointers, enough for the three patch ops. Unknown tokens fail the catalog. +internal static class JsonPointer +{ + public static void Add(JsonNode root, string pointer, JsonNode value) + { + var (parent, token) = LocateParent(root, pointer); + if (token == "-") + { + if (parent is not JsonArray array) + { + throw new ContentLoadException($"JSON Pointer '{pointer}' cannot append: the parent is not an array."); + } + + array.Add(value.DeepClone()); + return; + } + + switch (parent) + { + case JsonObject obj: + obj[token] = value.DeepClone(); + break; + + case JsonArray array: + array.Insert(ParseIndex(token, pointer, array.Count + 1), value.DeepClone()); + break; + + default: + throw new ContentLoadException($"JSON Pointer '{pointer}' cannot add here."); + } + } + + public static void Replace(JsonNode root, string pointer, JsonNode value) + { + var (parent, token) = LocateParent(root, pointer); + switch (parent) + { + case JsonObject obj when obj.ContainsKey(token): + obj[token] = value.DeepClone(); + break; + + case JsonArray array: + var index = ParseIndex(token, pointer, array.Count); + array[index] = value.DeepClone(); + break; + + default: + throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for replace."); + } + } + + public static void Remove(JsonNode root, string pointer) + { + var (parent, token) = LocateParent(root, pointer); + switch (parent) + { + case JsonObject obj when obj.Remove(token): + break; + + case JsonArray array: + var index = ParseIndex(token, pointer, array.Count); + array.RemoveAt(index); + break; + + default: + throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for remove."); + } + } + + private static (JsonNode Parent, string Token) LocateParent(JsonNode root, string pointer) + { + var tokens = Tokens(pointer); + if (tokens.Length == 0) + { + throw new ContentLoadException("A patch path cannot target the document root."); + } + + JsonNode current = root; + for (var i = 0; i < tokens.Length - 1; i++) + { + current = Step(current, tokens[i], pointer); + } + + return (current, tokens[^1]); + } + + private static JsonNode Step(JsonNode node, string token, string pointer) + { + switch (node) + { + case JsonObject obj when obj.TryGetPropertyValue(token, out var child) && child is not null: + return child; + + case JsonArray array: + var index = ParseIndex(token, pointer, array.Count); + return array[index] ?? throw new ContentLoadException($"JSON Pointer '{pointer}' hit a null array slot."); + + default: + throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist."); + } + } + + private static string[] Tokens(string pointer) + { + if (pointer.Length == 0) + { + return []; + } + + if (pointer[0] != '/') + { + throw new ContentLoadException($"JSON Pointer '{pointer}' must start with '/'."); + } + + return pointer.Split('/').Skip(1).Select(Unescape).ToArray(); + } + + private static string Unescape(string token) => token.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal); + + private static int ParseIndex(string token, string pointer, int count) + { + if (!int.TryParse(token, out var index) || index < 0 || index >= count) + { + throw new ContentLoadException($"JSON Pointer '{pointer}' has a bad array index."); + } + + return index; + } +} diff --git a/src/HSchool.Content/Jsonc.cs b/src/HSchool.Content/Jsonc.cs new file mode 100644 index 0000000..69f2c73 --- /dev/null +++ b/src/HSchool.Content/Jsonc.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HSchool.Content; + +/// JSON with comments and trailing commas — the format of every def, patch, locale and map file. +public static class Jsonc +{ + public static JsonSerializerOptions SerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + WriteIndented = true, + }; + + public static JsonDocumentOptions DocumentOptions { get; } = new() + { + // Skip, not Allow: JsonDocument will not store comments. The files may still contain them. + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public static JsonNode Parse(string text, string? source = null) + { + try + { + var node = JsonNode.Parse(text, documentOptions: DocumentOptions); + if (node is null) + { + throw new ContentLoadException(source is null ? "JSONC was empty." : $"JSONC {source} was empty."); + } + + return node; + } + catch (JsonException ex) + { + var where = source is null ? "JSONC" : $"JSONC {source}"; + throw new ContentLoadException($"{where} is not valid: {ex.Message}", ex); + } + } + + public static T Deserialize(JsonNode node) + { + var value = node.Deserialize(SerializerOptions); + if (value is null) + { + throw new ContentLoadException($"Could not read a {typeof(T).Name} from JSONC."); + } + + return value; + } +} diff --git a/src/HSchool.Content/MapLayout.cs b/src/HSchool.Content/MapLayout.cs new file mode 100644 index 0000000..62ec931 --- /dev/null +++ b/src/HSchool.Content/MapLayout.cs @@ -0,0 +1,70 @@ +namespace HSchool.Content; + +/// One school's map instance: tree grouping plus a walkable graph of territory and rooms. +public sealed class MapLayout +{ + public TerritoryNode? Territory { get; init; } + + public IReadOnlyList Buildings { get; init; } = []; + + public IReadOnlyList Floors { get; init; } = []; + + public IReadOnlyList Rooms { get; init; } = []; + + public IReadOnlyList Links { get; init; } = []; + + public static MapLayout Parse(string jsonc, string? source = null) => + Jsonc.Deserialize(Jsonc.Parse(jsonc, source)); +} + +public sealed class TerritoryNode +{ + public required string Id { get; init; } + + public required string Def { get; init; } +} + +public sealed class BuildingNode +{ + public required string Id { get; init; } + + public required string Def { get; init; } +} + +public sealed class FloorNode +{ + public required string Id { get; init; } + + public required string Def { get; init; } + + public required string Building { get; init; } + + public string? Label { get; init; } +} + +public sealed class RoomNode +{ + public required string Id { get; init; } + + public required string Def { get; init; } + + public required string Building { get; init; } + + public required string Floor { get; init; } + + public IReadOnlyList Slots { get; init; } = []; +} + +public sealed class SlotFill +{ + public required string Key { get; init; } + + public required string Thing { get; init; } +} + +public sealed class MapLink +{ + public required string A { get; init; } + + public required string B { get; init; } +} diff --git a/src/HSchool.Content/MapValidator.cs b/src/HSchool.Content/MapValidator.cs new file mode 100644 index 0000000..b916970 --- /dev/null +++ b/src/HSchool.Content/MapValidator.cs @@ -0,0 +1,171 @@ +namespace HSchool.Content; + +/// +/// Checks that a map instance is a connected undirected graph rooted at a yard, with every +/// def present and non-abstract in the school's catalog. +/// +public static class MapValidator +{ + public static void Validate(MapLayout map, DefCatalog catalog) + { + if (map.Territory is not { Id.Length: > 0, Def.Length: > 0 } territory) + { + throw new MapValidationException("The map has no territory."); + } + + if (map.Rooms.Count == 0) + { + throw new MapValidationException("A map needs a territory and at least one room."); + } + + RequireConcrete(catalog, DefKind.Territory, territory.Def, territory.Id); + + var ids = new Dictionary(StringComparer.Ordinal); + AddId(ids, territory.Id, "territory"); + + var buildings = new Dictionary(StringComparer.Ordinal); + foreach (var building in map.Buildings) + { + AddId(ids, building.Id, "building"); + RequireConcrete(catalog, DefKind.Building, building.Def, building.Id); + buildings[building.Id] = building; + } + + var floors = new Dictionary(StringComparer.Ordinal); + foreach (var floor in map.Floors) + { + AddId(ids, floor.Id, "floor"); + RequireConcrete(catalog, DefKind.Floor, floor.Def, floor.Id); + if (!buildings.ContainsKey(floor.Building)) + { + throw new MapValidationException($"Floor '{floor.Id}' references unknown building '{floor.Building}'."); + } + + floors[floor.Id] = floor; + } + + var rooms = new Dictionary(StringComparer.Ordinal); + foreach (var room in map.Rooms) + { + AddId(ids, room.Id, "room"); + RequireConcrete(catalog, DefKind.Room, room.Def, room.Id); + if (!buildings.ContainsKey(room.Building)) + { + throw new MapValidationException($"Room '{room.Id}' references unknown building '{room.Building}'."); + } + + if (!floors.TryGetValue(room.Floor, out var floor)) + { + throw new MapValidationException($"Room '{room.Id}' references unknown floor '{room.Floor}'."); + } + + if (!floor.Building.Equals(room.Building, StringComparison.Ordinal)) + { + throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building."); + } + + ValidateSlotFills(room, catalog); + rooms[room.Id] = room; + } + + var walkable = new HashSet(StringComparer.Ordinal) { territory.Id }; + foreach (var roomId in rooms.Keys) + { + walkable.Add(roomId); + } + + var adjacency = walkable.ToDictionary(id => id, _ => new List(), StringComparer.Ordinal); + foreach (var link in map.Links) + { + if (!walkable.Contains(link.A) || !walkable.Contains(link.B)) + { + throw new MapValidationException($"Link '{link.A}' → '{link.B}' points at an unknown node."); + } + + if (link.A.Equals(link.B, StringComparison.Ordinal)) + { + throw new MapValidationException($"Link '{link.A}' → '{link.B}' is a self-loop."); + } + + adjacency[link.A].Add(link.B); + adjacency[link.B].Add(link.A); + } + + foreach (var (id, neighbours) in adjacency) + { + if (neighbours.Count == 0) + { + throw new MapValidationException($"Node '{id}' is isolated."); + } + } + + var seen = new HashSet(StringComparer.Ordinal); + var queue = new Queue(); + queue.Enqueue(territory.Id); + seen.Add(territory.Id); + while (queue.Count > 0) + { + var id = queue.Dequeue(); + foreach (var next in adjacency[id]) + { + if (seen.Add(next)) + { + queue.Enqueue(next); + } + } + } + + if (seen.Count != walkable.Count) + { + throw new MapValidationException("The map graph is not connected."); + } + } + + private static void ValidateSlotFills(RoomNode room, DefCatalog catalog) + { + if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef)) + { + return; + } + + var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal); + foreach (var fill in room.Slots) + { + if (!keys.Contains(fill.Key)) + { + throw new MapValidationException($"Room '{room.Id}' fills unknown slot '{fill.Key}'."); + } + + if (!catalog.Things.TryGetValue(fill.Thing, out var thing) || thing.Abstract) + { + throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'."); + } + } + } + + private static void RequireConcrete(DefCatalog catalog, DefKind kind, string defName, string nodeId) + { + if (!catalog.TryGet(kind, defName, out var def)) + { + throw new MapValidationException($"Unknown {kind} '{defName}' on '{nodeId}'."); + } + + if (def.Abstract) + { + throw new MapValidationException($"Abstract def '{defName}' cannot be placed on the map ('{nodeId}')."); + } + } + + private static void AddId(Dictionary ids, string id, string kind) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new MapValidationException($"A {kind} is missing an id."); + } + + if (!ids.TryAdd(id, kind)) + { + throw new MapValidationException($"Map id '{id}' is used more than once."); + } + } +} diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs new file mode 100644 index 0000000..79bf6f8 --- /dev/null +++ b/src/HSchool.Content/PackPaths.cs @@ -0,0 +1,94 @@ +namespace HSchool.Content; + +internal static class PackPaths +{ + public static string Normalize(string relativePath) => relativePath.Replace('\\', '/').TrimStart('/'); + + public static bool IsJsonc(string relativePath) + { + var path = Normalize(relativePath); + return path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".json", StringComparison.OrdinalIgnoreCase); + } + + public static bool TryGetDefKind(string relativePath, out DefKind kind) + { + kind = default; + var parts = Normalize(relativePath).Split('/'); + if (parts.Length < 3 || !parts[0].Equals("defs", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return TryMapFolder(parts[1], out kind); + } + + public static bool IsPatch(string relativePath) + { + var path = Normalize(relativePath); + return path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase) && IsJsonc(path); + } + + public static bool TryGetLocaleLanguage(string relativePath, out string language) + { + language = string.Empty; + var path = Normalize(relativePath); + if (!path.StartsWith("localizations/", StringComparison.OrdinalIgnoreCase) || !IsJsonc(path)) + { + return false; + } + + var file = Path.GetFileNameWithoutExtension(path); + if (file.Equals("ru", StringComparison.OrdinalIgnoreCase)) + { + language = "ru"; + return true; + } + + if (file.Equals("en", StringComparison.OrdinalIgnoreCase)) + { + language = "en"; + return true; + } + + return false; + } + + public static bool IsDefaultMap(string relativePath) => + Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase) + || Normalize(relativePath).Equals("maps/default.json", StringComparison.OrdinalIgnoreCase); + + private static bool TryMapFolder(string folder, out DefKind kind) + { + switch (folder.ToLowerInvariant()) + { + case "actions": + kind = DefKind.Action; + return true; + case "things": + kind = DefKind.Thing; + return true; + case "positions": + kind = DefKind.Position; + return true; + case "works": + kind = DefKind.Work; + return true; + case "rooms": + kind = DefKind.Room; + return true; + case "buildings": + kind = DefKind.Building; + return true; + case "floors": + kind = DefKind.Floor; + return true; + case "territories": + kind = DefKind.Territory; + return true; + default: + kind = default; + return false; + } + } +} diff --git a/src/HSchool.Content/PatchApplier.cs b/src/HSchool.Content/PatchApplier.cs new file mode 100644 index 0000000..a9b5f8c --- /dev/null +++ b/src/HSchool.Content/PatchApplier.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Nodes; + +namespace HSchool.Content; + +internal sealed class PatchDocument +{ + public required string Target { get; init; } + + public IReadOnlyList Ops { get; init; } = []; +} + +internal sealed class PatchOp +{ + public required string Op { get; init; } + + public required string Path { get; init; } + + public JsonNode? Value { get; init; } +} + +internal static class PatchApplier +{ + public static void Apply(JsonObject target, PatchDocument patch) + { + foreach (var op in patch.Ops) + { + var name = op.Op.Trim().ToLowerInvariant(); + switch (name) + { + case "add": + JsonPointer.Add(target, op.Path, RequireValue(op, patch.Target)); + break; + + case "replace": + JsonPointer.Replace(target, op.Path, RequireValue(op, patch.Target)); + break; + + case "remove": + JsonPointer.Remove(target, op.Path); + break; + + default: + throw new ContentLoadException($"Unknown patch op '{op.Op}' on '{patch.Target}'."); + } + } + } + + private static JsonNode RequireValue(PatchOp op, string target) + { + if (op.Value is null) + { + throw new ContentLoadException($"Patch '{op.Op}' on '{target}' needs a value."); + } + + return op.Value; + } +} diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index f4436d7..ffadddf 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -1,3 +1,4 @@ +using HSchool.Content; using HSchool.Protocol; using HSchool.Server.Net; using HSchool.Simulation; @@ -15,6 +16,7 @@ internal sealed class GameLoopService( ClientRegistry clients, GameMetrics metrics, SchoolStore store, + ModContent mods, ILoggerFactory loggerFactory, ILogger logger) : BackgroundService { @@ -170,7 +172,7 @@ internal sealed class GameLoopService( var id = _nextId++; store.WriteNextId(_nextId); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true); + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null); Track(worker); worker.Start(); @@ -327,15 +329,25 @@ internal sealed class GameLoopService( save.GameTime, save.Running, save.SpeedIndex, - isNew: false); - Track(worker); + isNew: false, + save.ModIds, + save.Map); worker.Start(); - } - if (_workers.Count > 0) - { - await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false); - logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count); + try + { + await worker.Started.ConfigureAwait(false); + Track(worker); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "School {SchoolId} \"{Name}\" was not started; the save file is unchanged.", + save.Id, + save.Name); + await worker.StopAsync(persist: false).ConfigureAwait(false); + } } } @@ -350,9 +362,22 @@ internal sealed class GameLoopService( { await Task.WhenAll(stopping).ConfigureAwait(false); } + + if (_workers.Count > 0) + { + logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count); + } } - private SchoolWorker SpawnWorker(int id, string name, DateTime time, bool running, int speedIndex, bool isNew) => + private SchoolWorker SpawnWorker( + int id, + string name, + DateTime time, + bool running, + int speedIndex, + bool isNew, + IReadOnlyList? modIds, + MapLayout? map) => new( id, name, @@ -360,10 +385,13 @@ internal sealed class GameLoopService( running, speedIndex, isNew, + modIds, + map, _options, clients, metrics, store, + mods, loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}")); private void Track(SchoolWorker worker) diff --git a/src/HSchool.Server/Game/LoggerContentLog.cs b/src/HSchool.Server/Game/LoggerContentLog.cs new file mode 100644 index 0000000..0343051 --- /dev/null +++ b/src/HSchool.Server/Game/LoggerContentLog.cs @@ -0,0 +1,8 @@ +using HSchool.Content; + +namespace HSchool.Server.Game; + +internal sealed class LoggerContentLog(ILogger logger) : IContentLog +{ + public void Warning(string message) => logger.LogWarning("{Message}", message); +} diff --git a/src/HSchool.Server/Game/ModContent.cs b/src/HSchool.Server/Game/ModContent.cs new file mode 100644 index 0000000..aa7cf1e --- /dev/null +++ b/src/HSchool.Server/Game/ModContent.cs @@ -0,0 +1,94 @@ +using HSchool.Content; +using HSchool.Simulation; +using Microsoft.Extensions.Options; + +namespace HSchool.Server.Game; + +/// +/// Reads mods/<id>/ from disk and hands the files to . +/// Content itself never sees these paths. +/// +internal sealed class ModContent +{ + private readonly CatalogLoader _loader = new(); + private readonly ILogger _logger; + + public ModContent(IOptions options, IHostEnvironment environment, ILogger logger) + { + _logger = logger; + + var configured = options.Value.ModsDirectory; + Root = Path.IsPathRooted(configured) + ? configured + : Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured)); + + logger.LogInformation("Mod packs directory is {Directory}.", Root); + } + + public string Root { get; } + + public bool PackExists(string packId) => Directory.Exists(PackPath(packId)); + + public IReadOnlyList NormalizePackIds(IReadOnlyList? extraModIds) => + CatalogLoader.NormalizePackOrder(extraModIds ?? []); + + public IReadOnlyList ReadDocuments(IReadOnlyList packIds) + { + var documents = new List(); + foreach (var packId in packIds) + { + var packRoot = PackPath(packId); + if (!Directory.Exists(packRoot)) + { + throw new SchoolContentUnavailableException($"Mod folder '{packId}' is missing under {Root}."); + } + + foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/'); + documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path))); + } + } + + return documents; + } + + public DefCatalog LoadCatalog(IReadOnlyList packIds, ILogger workerLog) + { + var documents = ReadDocuments(packIds); + try + { + return _loader.Load(packIds, documents, new LoggerContentLog(workerLog)); + } + catch (ContentLoadException ex) + { + throw new SchoolContentUnavailableException(ex.Message, ex); + } + } + + public MapLayout LoadMap(IReadOnlyList packIds, MapLayout? saved) + { + if (saved is not null) + { + return saved; + } + + var documents = ReadDocuments(packIds); + var map = CatalogLoader.LastDefaultMap(packIds, documents); + if (map is null) + { + throw new SchoolContentUnavailableException( + $"No maps/default.jsonc found for packs [{string.Join(", ", packIds)}]."); + } + + return map; + } + + private string PackPath(string packId) => Path.Combine(Root, packId); +} diff --git a/src/HSchool.Server/Game/SchoolContentUnavailableException.cs b/src/HSchool.Server/Game/SchoolContentUnavailableException.cs new file mode 100644 index 0000000..aa81f4d --- /dev/null +++ b/src/HSchool.Server/Game/SchoolContentUnavailableException.cs @@ -0,0 +1,16 @@ +namespace HSchool.Server.Game; + +/// +/// The school's pack list cannot be turned into a catalog (missing folder, broken defs, bad map). +/// The save file stays on disk; this school simply does not start. +/// +internal sealed class SchoolContentUnavailableException : Exception +{ + public SchoolContentUnavailableException(string message) : base(message) + { + } + + public SchoolContentUnavailableException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index d1ba161..1d5c7f6 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -1,11 +1,29 @@ using System.Text.Json; +using HSchool.Content; using HSchool.Simulation; using Microsoft.Extensions.Options; namespace HSchool.Server.Game; /// On-disk record of one school. Extra JSON fields are ignored so later slices can grow it. -internal sealed record SchoolSave(int Format, int Id, string Name, DateTime GameTime, bool Running, int SpeedIndex); +internal sealed class SchoolSave +{ + public int Format { get; init; } + + public int Id { get; init; } + + public string Name { get; init; } = ""; + + public DateTime GameTime { get; init; } + + public bool Running { get; init; } + + public int SpeedIndex { get; init; } + + public IReadOnlyList? ModIds { get; init; } + + public MapLayout? Map { get; init; } +} /// Allocates school ids that survive a process restart. internal sealed record SchoolSaveIndex(int NextId); @@ -16,7 +34,7 @@ internal sealed record SchoolSaveIndex(int NextId); /// internal sealed class SchoolStore { - public const int CurrentFormat = 1; + public const int CurrentFormat = 2; private const string IndexFileName = "index.json"; @@ -99,7 +117,17 @@ internal sealed class SchoolStore continue; } - saves.Add(save with { GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc) }); + saves.Add(new SchoolSave + { + Format = save.Format, + Id = save.Id, + Name = save.Name, + GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc), + Running = save.Running, + SpeedIndex = save.SpeedIndex, + ModIds = save.ModIds, + Map = save.Map, + }); } catch (Exception ex) { diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 2f7825c..c94b586 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Threading.Channels; +using HSchool.Content; using HSchool.Protocol; using HSchool.Server.Net; using HSchool.Simulation; @@ -8,8 +9,8 @@ namespace HSchool.Server.Game; /// /// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's -/// save file. Awaits are resolved with GetResult so stays on -/// this thread instead of hopping back onto the pool. +/// frozen catalog, that school's save file. Awaits are resolved with GetResult so +/// stays on this thread instead of hopping back onto the pool. /// internal sealed class SchoolWorker { @@ -19,12 +20,15 @@ internal sealed class SchoolWorker private readonly ClientRegistry _clients; private readonly GameMetrics _metrics; private readonly SchoolStore _store; + private readonly ModContent _mods; private readonly ILogger _logger; private readonly Channel _mailbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenSource _stopping = new(); private readonly bool _isNew; + private readonly IReadOnlyList? _modIds; + private readonly MapLayout? _savedMap; private readonly int _id; private readonly string _name; @@ -44,10 +48,13 @@ internal sealed class SchoolWorker bool running, int speedIndex, bool isNew, + IReadOnlyList? modIds, + MapLayout? savedMap, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, SchoolStore store, + ModContent mods, ILogger logger) { _id = id; @@ -56,10 +63,13 @@ internal sealed class SchoolWorker _running = running; _speedIndex = speedIndex; _isNew = isNew; + _modIds = modIds; + _savedMap = savedMap; _options = options; _clients = clients; _metrics = metrics; _store = store; + _mods = mods; _logger = logger; _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex); } @@ -113,6 +123,11 @@ internal sealed class SchoolWorker { RunLoop(_stopping.Token); } + catch (SchoolContentUnavailableException ex) + { + _logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id); + _started.TrySetException(ex); + } catch (Exception ex) { _logger.LogError(ex, "School {SchoolId} worker died.", _id); @@ -122,9 +137,30 @@ internal sealed class SchoolWorker private void RunLoop(CancellationToken cancellationToken) { + var packIds = _mods.NormalizePackIds(_modIds); + foreach (var packId in packIds) + { + if (!_mods.PackExists(packId)) + { + throw new SchoolContentUnavailableException( + $"School {_id} needs mod '{packId}', but that folder is missing."); + } + } + + var catalog = _mods.LoadCatalog(packIds, _logger); + var map = _mods.LoadMap(packIds, _savedMap); + try + { + MapValidator.Validate(map, catalog); + } + catch (MapValidationException ex) + { + throw new SchoolContentUnavailableException(ex.Message, ex); + } + var school = _isNew - ? School.Create(_id, _name, _time) - : School.Load(_id, _name, _time, _running, _speedIndex); + ? School.Create(_id, _name, _time, catalog, map) + : School.Load(_id, _name, _time, _running, _speedIndex, catalog, map); _school = school; PublishSnapshot(); @@ -298,13 +334,17 @@ internal sealed class SchoolWorker return; } - _store.Save(new SchoolSave( - SchoolStore.CurrentFormat, - school.Id, - school.Name, - school.Clock.Time, - school.Clock.IsRunning, - school.Clock.SpeedIndex)); + _store.Save(new SchoolSave + { + Format = SchoolStore.CurrentFormat, + Id = school.Id, + Name = school.Name, + GameTime = school.Clock.Time, + Running = school.Clock.IsRunning, + SpeedIndex = school.Clock.SpeedIndex, + ModIds = school.Catalog?.PackIds, + Map = school.Map, + }); } private void BroadcastClock() diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj index 484983f..bc0ebda 100644 --- a/src/HSchool.Server/HSchool.Server.csproj +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -12,6 +12,14 @@ + + + + + + PreserveNewest + PreserveNewest + diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 2f32408..7132812 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -19,6 +19,7 @@ builder.Services .Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.") .Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.") .Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.") + .Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.") .Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.") .ValidateOnStart(); @@ -26,6 +27,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index b0aaa9f..49944e3 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -12,6 +12,7 @@ "GameMinutesPerRealSecond": 5, "DefaultStartDate": "2012-04-03T06:00:00", "SavesDirectory": "saves", + "ModsDirectory": "mods", "SaveIntervalSeconds": 30 } } diff --git a/src/HSchool.Server/mods/core/defs/actions/sit.jsonc b/src/HSchool.Server/mods/core/defs/actions/sit.jsonc new file mode 100644 index 0000000..cdf0e6f --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/actions/sit.jsonc @@ -0,0 +1 @@ +{ "defName": "Sit" } diff --git a/src/HSchool.Server/mods/core/defs/buildings/main-building.jsonc b/src/HSchool.Server/mods/core/defs/buildings/main-building.jsonc new file mode 100644 index 0000000..23ee75f --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/buildings/main-building.jsonc @@ -0,0 +1 @@ +{ "defName": "MainBuilding" } diff --git a/src/HSchool.Server/mods/core/defs/floors/standard-floor.jsonc b/src/HSchool.Server/mods/core/defs/floors/standard-floor.jsonc new file mode 100644 index 0000000..547322e --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/floors/standard-floor.jsonc @@ -0,0 +1 @@ +{ "defName": "StandardFloor" } diff --git a/src/HSchool.Server/mods/core/defs/positions/principal.jsonc b/src/HSchool.Server/mods/core/defs/positions/principal.jsonc new file mode 100644 index 0000000..7b907e9 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/positions/principal.jsonc @@ -0,0 +1 @@ +{ "defName": "Principal" } diff --git a/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc b/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc new file mode 100644 index 0000000..f5a0b4b --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc @@ -0,0 +1,2 @@ +// Empty on purpose: a corridor is a walkable room with no furniture of its own. +{ "defName": "Corridor" } diff --git a/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc b/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc new file mode 100644 index 0000000..3482d93 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc @@ -0,0 +1,10 @@ +{ + "defName": "PrincipalsOffice", + "slots": [ + { "key": "directorChair", "thing": "DirectorsChair" }, + { "key": "desk", "thing": "Desk" }, + { "key": "guestChair", "thing": "Chair", "count": 2 }, + ], + "positions": ["Principal"], + "works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"], +} diff --git a/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc b/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc new file mode 100644 index 0000000..f2f6fa4 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc @@ -0,0 +1 @@ +{ "defName": "SchoolYard" } diff --git a/src/HSchool.Server/mods/core/defs/things/chair.jsonc b/src/HSchool.Server/mods/core/defs/things/chair.jsonc new file mode 100644 index 0000000..f2b2a9a --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/things/chair.jsonc @@ -0,0 +1 @@ +{ "defName": "Chair", "actions": ["Sit"] } diff --git a/src/HSchool.Server/mods/core/defs/things/desk.jsonc b/src/HSchool.Server/mods/core/defs/things/desk.jsonc new file mode 100644 index 0000000..ac1ab5f --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/things/desk.jsonc @@ -0,0 +1 @@ +{ "defName": "Desk" } diff --git a/src/HSchool.Server/mods/core/defs/things/directors-chair.jsonc b/src/HSchool.Server/mods/core/defs/things/directors-chair.jsonc new file mode 100644 index 0000000..62d0edb --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/things/directors-chair.jsonc @@ -0,0 +1 @@ +{ "defName": "DirectorsChair", "parent": "Chair" } diff --git a/src/HSchool.Server/mods/core/defs/works/works.jsonc b/src/HSchool.Server/mods/core/defs/works/works.jsonc new file mode 100644 index 0000000..a89785c --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/works/works.jsonc @@ -0,0 +1,5 @@ +[ + { "defName": "PrincipalOfficeWork" }, + { "defName": "TeachLesson" }, + { "defName": "WalkSchool" }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc new file mode 100644 index 0000000..66af6e6 --- /dev/null +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -0,0 +1,15 @@ +{ + "Sit": "Sit", + "Chair": "Chair", + "DirectorsChair": "Principal's chair", + "Desk": "Desk", + "Principal": "Principal", + "PrincipalOfficeWork": "Principal's work", + "TeachLesson": "Teach a lesson", + "WalkSchool": "Walk the school", + "SchoolYard": "Yard", + "MainBuilding": "Main building", + "StandardFloor": "Floor", + "Corridor": "Corridor", + "PrincipalsOffice": "Principal's office", +} diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc new file mode 100644 index 0000000..706036a --- /dev/null +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -0,0 +1,15 @@ +{ + "Sit": "Сесть", + "Chair": "Стул", + "DirectorsChair": "Кресло директора", + "Desk": "Стол", + "Principal": "Директор", + "PrincipalOfficeWork": "Работа директора", + "TeachLesson": "Урок", + "WalkSchool": "Обход школы", + "SchoolYard": "Двор", + "MainBuilding": "Главный корпус", + "StandardFloor": "Этажи", + "Corridor": "Коридор", + "PrincipalsOffice": "Кабинет директора", +} diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc new file mode 100644 index 0000000..fc862f2 --- /dev/null +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -0,0 +1,33 @@ +{ + // Walkable yard is the tree root and a graph node. Rooms reach it through the porch/corridor. + "territory": { "id": "yard", "def": "SchoolYard" }, + "buildings": [ + { "id": "main", "def": "MainBuilding" }, + ], + "floors": [ + { "id": "floor-1", "def": "StandardFloor", "building": "main", "label": "1" }, + ], + "rooms": [ + { + "id": "corridor-1", + "def": "Corridor", + "building": "main", + "floor": "floor-1", + }, + { + "id": "principals-office", + "def": "PrincipalsOffice", + "building": "main", + "floor": "floor-1", + "slots": [ + { "key": "directorChair", "thing": "DirectorsChair" }, + { "key": "desk", "thing": "Desk" }, + { "key": "guestChair", "thing": "Chair" }, + ], + }, + ], + "links": [ + { "a": "yard", "b": "corridor-1" }, + { "a": "corridor-1", "b": "principals-office" }, + ], +} diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj index a787ba8..dec66fd 100644 --- a/src/HSchool.Simulation/HSchool.Simulation.csproj +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index d264e63..33b5ec3 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -1,11 +1,13 @@ using Arch.Core; +using HSchool.Content; namespace HSchool.Simulation; /// -/// One save: a name, a calendar and the ECS world that will hold everything the school is made of. -/// The world is empty for now — pupils, rooms and staff land in it as the game grows — but it is -/// created and destroyed with the school so ownership is never in question. +/// One save: a name, a calendar, a frozen def catalog, a map instance, and the ECS world that will +/// hold everything the school is made of. The world is empty for now — pupils, rooms and staff +/// land in it as the game grows — but it is created and destroyed with the school so ownership is +/// never in question. /// public sealed class School : IDisposable { @@ -14,21 +16,36 @@ public sealed class School : IDisposable private bool _disposed; - internal School(int id, string name, DateTime startDate) + internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map) { Id = id; Name = name; Clock = new GameClock(startDate); + Catalog = catalog; + Map = map; World = World.Create(); } /// A brand-new school: calendar running at the start date, empty world. - public static School Create(int id, string name, DateTime startDate) => new(id, name, startDate); + public static School Create( + int id, + string name, + DateTime startDate, + DefCatalog? catalog = null, + MapLayout? map = null) => + new(id, name, startDate, catalog, map); /// Rebuilds a school from a save. Time, pause and speed come from disk, not defaults. - public static School Load(int id, string name, DateTime time, bool running, int speedIndex) + public static School Load( + int id, + string name, + DateTime time, + bool running, + int speedIndex, + DefCatalog? catalog = null, + MapLayout? map = null) { - var school = new School(id, name, time); + var school = new School(id, name, time, catalog, map); school.Clock.IsRunning = running; school.Clock.SpeedIndex = speedIndex; return school; @@ -40,6 +57,12 @@ public sealed class School : IDisposable public GameClock Clock { get; } + /// Frozen at create/load. Null only in clock-only unit tests. + public DefCatalog? Catalog { get; } + + /// The school's map instance. Null only in clock-only unit tests. + public MapLayout? Map { get; } + /// The Arch world backing this school. Only this school's worker thread may touch it. public World World { get; } diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index a0adc7a..9de3394 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -31,6 +31,12 @@ public sealed class SimulationOptions /// public string SavesDirectory { get; set; } = "saves"; + /// + /// Directory of mod packs (core and optional add-ons). Relative paths are resolved + /// against the content root. + /// + public string ModsDirectory { get; set; } = "mods"; + /// /// How often a running school writes its clock to disk. Create, pause, speed and shutdown /// write immediately; the tick itself never does. diff --git a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs new file mode 100644 index 0000000..b42f646 --- /dev/null +++ b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs @@ -0,0 +1,115 @@ +namespace HSchool.Content.Tests; + +public class CatalogLoaderTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void Jsonc_AllowsCommentsAndTrailingCommas() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "actions", + "sit", + """ + // a verb the Sit system already knows + { "defName": "Sit", } + """), + ]); + + Assert.True(catalog.Actions.ContainsKey("Sit")); + } + + [Fact] + public void DuplicateDefName_LastPackWinsAndWarns() + { + var log = new RecordingLog(); + var catalog = _loader.Load( + [CatalogLoader.CorePackId, "addon"], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": [] }"""), + PackDocuments.Def("addon", "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + ], + log); + + Assert.Equal(["Sit"], catalog.Things["Chair"].Actions); + Assert.Contains(log.Warnings, warning => warning.Contains("Chair") && warning.Contains("addon")); + } + + [Fact] + public void DuplicateLocaleKey_LastPackWinsAndWarns() + { + var log = new RecordingLog(); + var catalog = _loader.Load( + [CatalogLoader.CorePackId, "addon"], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""), + PackDocuments.Locale("addon", "ru", """{ "Sit": "Присесть" }"""), + ], + log); + + Assert.Equal("Присесть", catalog.Label("ru", catalog.Actions["Sit"])); + Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon")); + } + + [Fact] + public void CoreIsAlwaysFirst_EvenIfOmittedFromThePackList() + { + var catalog = _loader.Load( + ["addon"], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave" }"""), + ]); + + Assert.Equal([CatalogLoader.CorePackId, "addon"], catalog.PackIds); + Assert.True(catalog.Actions.ContainsKey("Sit")); + Assert.True(catalog.Actions.ContainsKey("Wave")); + } + + [Fact] + public void ThingActions_MustExist() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }""")])); + + Assert.Contains("Sit", ex.Message); + } + + [Fact] + public void RoomSlotsAndPositions_MustExist() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "office", + """{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"] }"""), + ])); + + Assert.Contains("Chair", ex.Message); + } + + [Fact] + public void Label_FallsBackToParentThenDefName() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "base", """{ "defName": "FurnitureBase", "abstract": true }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "FurnitureBase" }"""), + PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "FurnitureBase": "Мебель" }"""), + ]); + + Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"])); + Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"])); + } +} diff --git a/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj b/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj new file mode 100644 index 0000000..a9d4b70 --- /dev/null +++ b/tests/HSchool.Content.Tests/HSchool.Content.Tests.csproj @@ -0,0 +1,30 @@ + + + + HSchool.Content.Tests + true + Exe + + + + + + + + + + + + + + + + + + + vanilla\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + diff --git a/tests/HSchool.Content.Tests/InheritanceTests.cs b/tests/HSchool.Content.Tests/InheritanceTests.cs new file mode 100644 index 0000000..89b7133 --- /dev/null +++ b/tests/HSchool.Content.Tests/InheritanceTests.cs @@ -0,0 +1,57 @@ +namespace HSchool.Content.Tests; + +public class InheritanceTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void ChildField_ReplacesParentArrayWholesale() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "base", + """{ "defName": "FurnitureBase", "abstract": true, "actions": ["Sit", "Inspect"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "chair", + """{ "defName": "Chair", "parent": "FurnitureBase", "actions": ["Sit"] }"""), + ]); + + Assert.Equal(["Sit"], catalog.Things["Chair"].Actions); + Assert.False(catalog.Things["Chair"].Abstract); + Assert.True(catalog.Things["FurnitureBase"].Abstract); + } + + [Fact] + public void CyclicParent_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "a", """{ "defName": "A", "parent": "B" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "b", """{ "defName": "B", "parent": "A" }"""), + ])); + + Assert.Contains("cyclic", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParentOfAnotherKind_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "rooms", "office", """{ "defName": "Office" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "Office" }"""), + ])); + + Assert.Contains("different kind", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/HSchool.Content.Tests/MapValidationTests.cs b/tests/HSchool.Content.Tests/MapValidationTests.cs new file mode 100644 index 0000000..726c202 --- /dev/null +++ b/tests/HSchool.Content.Tests/MapValidationTests.cs @@ -0,0 +1,174 @@ +namespace HSchool.Content.Tests; + +public class MapValidationTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void ConnectedMap_WithAnEmptyRoom_IsValid() + { + var (catalog, map) = MiniSchool(fillOffice: false); + MapValidator.Validate(map, catalog); + Assert.Empty(map.Rooms.Single(room => room.Id == "office").Slots); + Assert.Equal(["Principal"], catalog.PositionsFor(DefKind.Room, "Office")); + } + + [Fact] + public void AbstractDef_CannotBePlacedOnTheMap() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + MiniDefs(abstractYard: true)); + var map = MiniMap(); + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("Abstract", ex.Message); + } + + [Fact] + public void UnknownDef_IsRejected() + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = MiniMap(officeDef: "MissingOffice"); + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("Unknown", ex.Message); + } + + [Fact] + public void EdgeToNowhere_IsRejected() + { + var (catalog, map) = MiniSchool(extraLink: new MapLink { A = "office", B = "ghost" }); + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("unknown node", ex.Message); + } + + [Fact] + public void IsolatedNode_IsRejected() + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = MiniMap(includeOfficeLink: false); + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("isolated", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DisconnectedGraph_IsRejected() + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }], + Rooms = + [ + new RoomNode { Id = "office", Def = "Office", Building = "main", Floor = "floor-1" }, + new RoomNode { Id = "a", Def = "Office", Building = "main", Floor = "floor-1" }, + new RoomNode { Id = "b", Def = "Office", Building = "main", Floor = "floor-1" }, + ], + Links = + [ + new MapLink { A = "yard", B = "office" }, + new MapLink { A = "a", B = "b" }, + ], + }; + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("not connected", ex.Message); + } + + [Fact] + public void OneRoomWithoutAYard_IsRejected() + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = new MapLayout + { + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }], + Rooms = [new RoomNode { Id = "office", Def = "Office", Building = "main", Floor = "floor-1" }], + Links = [new MapLink { A = "office", B = "office" }], + }; + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("no territory", ex.Message); + } + + [Fact] + public void MapWithNoRooms_IsRejected() + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + }; + + var ex = Assert.Throws(() => MapValidator.Validate(map, catalog)); + Assert.Contains("at least one room", ex.Message); + } + + private (DefCatalog Catalog, MapLayout Map) MiniSchool(bool fillOffice = true, MapLink? extraLink = null) + { + var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs()); + var map = MiniMap(fillOffice: fillOffice, extraLink: extraLink); + return (catalog, map); + } + + private static List MiniDefs(bool abstractYard = false) => + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "positions", "principal", """{ "defName": "Principal" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "territories", + "yard", + abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "buildings", "main", """{ "defName": "Main" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "office", + """{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"] }"""), + ]; + + private static MapLayout MiniMap( + bool fillOffice = false, + bool includeOfficeLink = true, + string officeDef = "Office", + MapLink? extraLink = null) + { + var links = new List(); + if (includeOfficeLink) + { + links.Add(new MapLink { A = "yard", B = "office" }); + } + + if (extraLink is not null) + { + links.Add(extraLink); + } + + return new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }], + Rooms = + [ + new RoomNode + { + Id = "office", + Def = officeDef, + Building = "main", + Floor = "floor-1", + Slots = fillOffice ? [new SlotFill { Key = "seat", Thing = "Chair" }] : [], + }, + ], + Links = links, + }; + } +} diff --git a/tests/HSchool.Content.Tests/PackDocuments.cs b/tests/HSchool.Content.Tests/PackDocuments.cs new file mode 100644 index 0000000..db1cfb2 --- /dev/null +++ b/tests/HSchool.Content.Tests/PackDocuments.cs @@ -0,0 +1,41 @@ +namespace HSchool.Content.Tests; + +internal sealed class RecordingLog : IContentLog +{ + public List Warnings { get; } = []; + + public void Warning(string message) => Warnings.Add(message); +} + +internal static class PackDocuments +{ + public static ContentDocument Def(string packId, string folder, string file, string jsonc) => + new(packId, $"defs/{folder}/{file}.jsonc", jsonc); + + public static ContentDocument Patch(string packId, string file, string jsonc) => + new(packId, $"patches/{file}.jsonc", jsonc); + + public static ContentDocument Locale(string packId, string language, string jsonc) => + new(packId, $"localizations/{language}.jsonc", jsonc); + + public static ContentDocument Map(string packId, string jsonc) => + new(packId, "maps/default.jsonc", jsonc); + + public static IReadOnlyList FromDirectory(string packId, string packRoot) + { + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/'); + documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path))); + } + + return documents; + } +} diff --git a/tests/HSchool.Content.Tests/PatchTests.cs b/tests/HSchool.Content.Tests/PatchTests.cs new file mode 100644 index 0000000..7ee0d11 --- /dev/null +++ b/tests/HSchool.Content.Tests/PatchTests.cs @@ -0,0 +1,97 @@ +namespace HSchool.Content.Tests; + +public class PatchTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void Add_AppendsToActions() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId, "addon"], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), + PackDocuments.Patch( + "addon", + "chair-inspect", + """ + { "target": "Chair", "ops": [ { "op": "add", "path": "/actions/-", "value": "Inspect" } ] } + """), + ]); + + Assert.Equal(["Sit", "Inspect"], catalog.Things["Chair"].Actions); + } + + [Fact] + public void UnknownOp_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""), + PackDocuments.Patch( + CatalogLoader.CorePackId, + "bad", + """{ "target": "Chair", "ops": [ { "op": "move", "path": "/actions" } ] }"""), + ])); + + Assert.Contains("Unknown patch op", ex.Message); + } + + [Fact] + public void MissingTarget_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""), + PackDocuments.Patch( + CatalogLoader.CorePackId, + "ghost", + """{ "target": "Missing", "ops": [ { "op": "remove", "path": "/actions" } ] }"""), + ])); + + Assert.Contains("was not found", ex.Message); + } + + [Fact] + public void ReplaceAndRemove_EditRoomDef() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId, "addon"], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "things", "desk", """{ "defName": "Desk" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "works", "teach", """{ "defName": "TeachLesson" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "works", "walk", """{ "defName": "WalkSchool" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "office", + """ + { + "defName": "Office", + "slots": [ { "key": "seat", "thing": "Chair" } ], + "works": ["TeachLesson", "WalkSchool"] + } + """), + PackDocuments.Patch( + "addon", + "office", + """ + { + "target": "Office", + "ops": [ + { "op": "replace", "path": "/slots/0/thing", "value": "Desk" }, + { "op": "remove", "path": "/works/1" } + ] + } + """), + ]); + + Assert.Equal("Desk", catalog.Rooms["Office"].Slots[0].Thing); + Assert.Equal(["TeachLesson"], catalog.Rooms["Office"].Works); + } +} diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs new file mode 100644 index 0000000..0667314 --- /dev/null +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -0,0 +1,25 @@ +namespace HSchool.Content.Tests; + +public class VanillaCoreTests +{ + [Fact] + public void CoreDefaultMap_PassesValidation() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + Assert.True(Directory.Exists(root), $"Vanilla core pack was not copied to {root}."); + + var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root); + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + + Assert.NotNull(map); + MapValidator.Validate(map, catalog); + + Assert.Equal("SchoolYard", catalog.Territories["SchoolYard"].DefName); + Assert.True(catalog.Rooms["Corridor"].Slots.Count == 0); + Assert.Equal(["Principal"], catalog.PositionsFor(DefKind.Room, "PrincipalsOffice")); + Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"])); + Assert.Equal("Principal's office", catalog.Label("en", catalog.Rooms["PrincipalsOffice"])); + Assert.Equal(["Sit"], catalog.Things["DirectorsChair"].Actions); + } +} diff --git a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj index 1b293c1..1d20310 100644 --- a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj +++ b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/HSchool.Simulation.Tests/SchoolTests.cs b/tests/HSchool.Simulation.Tests/SchoolTests.cs index 0fe9175..fab7737 100644 --- a/tests/HSchool.Simulation.Tests/SchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/SchoolTests.cs @@ -1,3 +1,5 @@ +using HSchool.Content; + namespace HSchool.Simulation.Tests; public class SchoolTests @@ -39,4 +41,36 @@ public class SchoolTests Assert.Equal(Start.AddMinutes(12), school.Clock.Time); } + + [Fact] + public void Create_WithACatalogAndMap_KeepsThemOnTheSchool() + { + var loader = new CatalogLoader(); + var documents = new[] + { + new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit" }"""), + new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""), + new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard" }"""), + new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""), + new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""), + new ContentDocument("core", "defs/rooms/office.jsonc", """{ "defName": "Office" }"""), + }; + var catalog = loader.Load(["core"], documents); + var map = new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }], + Rooms = [new RoomNode { Id = "office", Def = "Office", Building = "main", Floor = "floor-1" }], + Links = [new MapLink { A = "yard", B = "office" }], + }; + MapValidator.Validate(map, catalog); + + using var school = School.Create(1, "С картой", Start, catalog, map); + school.Tick(1d / 20d, 5d); + + Assert.Same(catalog, school.Catalog); + Assert.Same(map, school.Map); + Assert.True(school.Clock.Time > Start); + } }