Add HSchool.Content project for JSONC definitions, catalog, and map validation. Update solution structure to include new content and tests projects. Enhance school management to support mod packs and map instances, ensuring proper loading and validation. Revise documentation to reflect these changes and update tests for new functionality.
This commit is contained in:
@@ -9,6 +9,7 @@ way; this file is *how to work in them*.
|
|||||||
| I want to change… | Go to |
|
| I want to change… | Go to |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| schools, the game clock, game rules | `src/HSchool.Simulation` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| connection handling, workers, saves | `src/HSchool.Server` |
|
||||||
@@ -101,6 +102,8 @@ say so explicitly in the change description.
|
|||||||
## Testing policy
|
## Testing policy
|
||||||
|
|
||||||
- Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host.
|
- 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.
|
- 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
|
- 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
|
shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ dotnet test
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts.
|
- `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.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
|
- `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.
|
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/
|
src/
|
||||||
HSchool.Protocol/ binary wire format (shared contract with the client)
|
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.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.ServiceDefaults/ Aspire telemetry, health checks, resilience
|
||||||
HSchool.AppHost/ Aspire orchestration
|
HSchool.AppHost/ Aspire orchestration
|
||||||
HSchool.Client/ Vite + TypeScript UI
|
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 |
|
| `MaxSchools` | 6 | how many schools may exist at once |
|
||||||
| `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 |
|
| `GameMinutesPerRealSecond` | 5 | game minutes per real second at ×1 |
|
||||||
| `DefaultStartDate` | `2012-04-03T06:00:00` | prefilled start of a new school |
|
| `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
|
## What is deliberately missing
|
||||||
|
|
||||||
No persistence, no authentication, and nothing inside a school yet — every school owns an empty
|
No authentication, no action execution, and the create dialog does not yet pick mods or edit the
|
||||||
ECS world waiting for its first entities. Each of these has a seam described in
|
map — every new school gets the vanilla `core` layout. Each of these has a seam described in
|
||||||
[`docs/architecture.md`](docs/architecture.md).
|
[`docs/architecture.md`](docs/architecture.md).
|
||||||
|
|||||||
+14
-9
@@ -14,8 +14,11 @@ there is no UI on the server.
|
|||||||
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
|
│ │ ├── SchoolWorker ×N │ └──────────────────┘ │
|
||||||
│ │ │ └── School │ │
|
│ │ │ └── School │ │
|
||||||
│ │ │ ├─ Clock│ │
|
│ │ │ ├─ Clock│ │
|
||||||
|
│ │ │ ├─ Catalog (frozen Content) │
|
||||||
|
│ │ │ ├─ Map │
|
||||||
│ │ │ └─ World│ (Arch ECS, empty for now) │
|
│ │ │ └─ World│ (Arch ECS, empty for now) │
|
||||||
│ │ ├── SchoolStore │ saves/{id}.json │
|
│ │ ├── SchoolStore │ saves/{id}.json │
|
||||||
|
│ │ ├── ModContent │ mods/<id>/ │
|
||||||
│ │ └── ClientRegistry │ │
|
│ │ └── ClientRegistry │ │
|
||||||
│ └────────────────────────┘ │
|
│ └────────────────────────┘ │
|
||||||
│ │ OTLP logs / traces / metrics │
|
│ │ OTLP logs / traces / metrics │
|
||||||
@@ -29,14 +32,15 @@ there is no UI on the server.
|
|||||||
| Project | Role |
|
| Project | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
|
| `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.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
|
||||||
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, disk saves. |
|
| `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.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.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. |
|
| `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
|
Dependency direction is one-way: `Protocol ← Server → Simulation → Content`. Nothing in
|
||||||
about HTTP, and nothing in `Protocol` knows about schools.
|
`Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools.
|
||||||
|
|
||||||
## Two channels, on purpose
|
## 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`
|
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
|
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
|
snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The file also stores the
|
||||||
directory at process start. A file that cannot be read is left in place and logged.
|
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
|
## 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.
|
`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
|
- **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.
|
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
|
- **Create editor and the map snapshot**: the catalog and vanilla map exist; the player still
|
||||||
pack ids without a second persistence mechanism. That work lives in
|
cannot pick mods or see the tree from the server. That work lives in
|
||||||
[`phases/03-defs-map.md`](phases/03-defs-map.md).
|
[`phases/04-create-editor.md`](phases/04-create-editor.md).
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Нарезка проектов (целевая)
|
# Нарезка проектов (целевая)
|
||||||
|
|
||||||
Сейчас в коде: `Protocol ← Server → Simulation`. Этого мало, когда появятся defs, карта и моды.
|
Сейчас в коде: `Protocol ← Server → Simulation → Content`. Каталог не в Simulation (рядом с Arch)
|
||||||
Класть каталог в Simulation (рядом с Arch) или в Server (рядом с Kestrel) — оба варианта смешают
|
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
|
||||||
слои. Ниже — куда что идёт в этом срезе. `architecture.md` правится, когда код так и станет.
|
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -10,19 +10,19 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Проект `HSchool.Content` (+ тесты): JSONC, defs, локали, граф карты, валидация. Не Arch, не ASP.NET
|
- [x] Проект `HSchool.Content` (+ тесты): JSONC, defs, локали, граф карты, валидация. Не Arch, не ASP.NET
|
||||||
- [ ] Загрузчик пачки документов (сервер потом подставит папки `mods/`)
|
- [x] Загрузчик пачки документов (сервер потом подставит папки `mods/`)
|
||||||
- [ ] `core` всегда первый; дальше моды по списку; повтор `defName` / ключа локали — последний победил, warning в лог
|
- [x] `core` всегда первый; дальше моды по списку; повтор `defName` / ключа локали — последний победил, warning в лог
|
||||||
- [ ] Резолв ссылок `ThingDef.actions`, слотов RoomDef после чтения всего набора
|
- [x] Резолв ссылок `ThingDef.actions`, слотов RoomDef после чтения всего набора
|
||||||
- [ ] Карта-инстанс: двор (TerritoryDef) всегда есть; здания (BuildingDef), этажи (FloorDef + id), помещения, двусторонние рёбра
|
- [x] Карта-инстанс: двор (TerritoryDef) всегда есть; здания (BuildingDef), этажи (FloorDef + id), помещения, двусторонние рёбра
|
||||||
- [ ] Валидация: неизвестный def, ребро в никуда, несвязный граф, изолированный узел
|
- [x] Валидация: неизвестный def, ребро в никуда, несвязный граф, изолированный узел
|
||||||
- [ ] Пустая комната допустима; должности для панели — из def узла, если они там есть
|
- [x] Пустая комната допустима; должности для панели — из def узла, если они там есть
|
||||||
- [ ] Каталог замораживается на работнике при create/load; сейв хранит id модов и раскладку, не развёрнутый каталог
|
- [x] Каталог замораживается на работнике при create/load; сейв хранит id модов и раскладку, не развёрнутый каталог
|
||||||
- [ ] Нет папки мода из сейва — школу не стартовать, файл не удалять, в лог
|
- [x] Нет папки мода из сейва — школу не стартовать, файл не удалять, в лог
|
||||||
- [ ] Обновить ориентир в `AGENTS.md` (куда класть defs)
|
- [x] Обновить ориентир в `AGENTS.md` (куда класть defs)
|
||||||
- [ ] Наследование: `parent`, `abstract`, замена полей, запрет цикла и чужого вида, abstract нельзя на карту
|
- [x] Наследование: `parent`, `abstract`, замена полей, запрет цикла и чужого вида, abstract нельзя на карту
|
||||||
- [ ] Патчи из `patches/`: add / replace / remove по JSON Pointer; неизвестный op или нет target — ошибка каталога
|
- [x] Патчи из `patches/`: add / replace / remove по JSON Pointer; неизвестный op или нет target — ошибка каталога
|
||||||
- [ ] Тесты Content: фикстуры JSONC, last-wins, parent/abstract, патч add в actions, связность, пустая комната, одна комната без двора — отказ
|
- [x] Тесты Content: фикстуры JSONC, last-wins, parent/abstract, патч add в actions, связность, пустая комната, одна комната без двора — отказ
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,5 @@
|
|||||||
| [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется |
|
| [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется |
|
||||||
| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных |
|
| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных |
|
||||||
| [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа |
|
| [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, снимок при открытии |
|
| [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии |
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.5.2.0
|
VisualStudioVersion = 17.5.2.0
|
||||||
@@ -22,44 +23,140 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol.Tests", "t
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation.Tests", "tests\HSchool.Simulation.Tests\HSchool.Simulation.Tests.csproj", "{25518ACB-AC00-4DE7-7F61-2756A5F47A38}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation.Tests", "tests\HSchool.Simulation.Tests\HSchool.Simulation.Tests.csproj", "{25518ACB-AC00-4DE7-7F61-2756A5F47A38}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -73,6 +170,8 @@ Global
|
|||||||
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{962E7F03-8B12-5802-91AA-105EEC2060E4} = {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}
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns pack documents into a frozen <see cref="DefCatalog"/>. Callers supply already-read
|
||||||
|
/// files; this type never looks at the disk.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CatalogLoader
|
||||||
|
{
|
||||||
|
public const string CorePackId = "core";
|
||||||
|
|
||||||
|
public DefCatalog Load(
|
||||||
|
IReadOnlyList<string> packOrder,
|
||||||
|
IReadOnlyList<ContentDocument> documents,
|
||||||
|
IContentLog? log = null)
|
||||||
|
{
|
||||||
|
log ??= NullContentLog.Instance;
|
||||||
|
var order = NormalizePackOrder(packOrder);
|
||||||
|
|
||||||
|
var defs = new Dictionary<(DefKind Kind, string Name), RawDef>();
|
||||||
|
var localesRu = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var localesEn = new Dictionary<string, string>(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<string> NormalizePackOrder(IReadOnlyList<string> packOrder)
|
||||||
|
{
|
||||||
|
var order = new List<string> { 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<string> packOrder, IReadOnlyList<ContentDocument> 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<string, string> localesRu,
|
||||||
|
Dictionary<string, string> 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<PatchDocument>(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<JsonObject> 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<string>(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<string, string> 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<string>(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<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Child fields replace parent fields wholesale, including arrays.
|
||||||
|
/// <c>abstract</c> is a flag of this def, not inherited: a child of an abstract parent is
|
||||||
|
/// concrete unless it also says <c>abstract: true</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<string> packIds,
|
||||||
|
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||||||
|
Dictionary<string, string> ru,
|
||||||
|
Dictionary<string, string> en)
|
||||||
|
{
|
||||||
|
var actions = new Dictionary<string, ActionDef>(StringComparer.Ordinal);
|
||||||
|
var things = new Dictionary<string, ThingDef>(StringComparer.Ordinal);
|
||||||
|
var positions = new Dictionary<string, PositionDef>(StringComparer.Ordinal);
|
||||||
|
var works = new Dictionary<string, WorkDef>(StringComparer.Ordinal);
|
||||||
|
var rooms = new Dictionary<string, RoomDef>(StringComparer.Ordinal);
|
||||||
|
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
|
||||||
|
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
|
||||||
|
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var (key, json) in resolved)
|
||||||
|
{
|
||||||
|
switch (key.Kind)
|
||||||
|
{
|
||||||
|
case DefKind.Action:
|
||||||
|
actions[key.Name] = Jsonc.Deserialize<ActionDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Thing:
|
||||||
|
things[key.Name] = Jsonc.Deserialize<ThingDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Position:
|
||||||
|
positions[key.Name] = Jsonc.Deserialize<PositionDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Work:
|
||||||
|
works[key.Name] = Jsonc.Deserialize<WorkDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Room:
|
||||||
|
rooms[key.Name] = Jsonc.Deserialize<RoomDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Building:
|
||||||
|
buildings[key.Name] = Jsonc.Deserialize<BuildingDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Floor:
|
||||||
|
floors[key.Name] = Jsonc.Deserialize<FloorDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Territory:
|
||||||
|
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>A JSONC file from one pack. The server (or a test) already read the bytes.</summary>
|
||||||
|
public sealed record ContentDocument(string PackId, string RelativePath, string Text);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>A pack failed to become a catalog. The school that asked for it must not start.</summary>
|
||||||
|
public sealed class ContentLoadException : Exception
|
||||||
|
{
|
||||||
|
public ContentLoadException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContentLoadException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A map instance does not match its catalog or is not a connected yard-rooted graph.</summary>
|
||||||
|
public sealed class MapValidationException : Exception
|
||||||
|
{
|
||||||
|
public MapValidationException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frozen set of defs and locale strings for one school. Built once at create/load; the worker
|
||||||
|
/// never re-reads pack files afterwards.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DefCatalog
|
||||||
|
{
|
||||||
|
internal DefCatalog(
|
||||||
|
IReadOnlyList<string> packIds,
|
||||||
|
IReadOnlyDictionary<string, ActionDef> actions,
|
||||||
|
IReadOnlyDictionary<string, ThingDef> things,
|
||||||
|
IReadOnlyDictionary<string, PositionDef> positions,
|
||||||
|
IReadOnlyDictionary<string, WorkDef> works,
|
||||||
|
IReadOnlyDictionary<string, RoomDef> rooms,
|
||||||
|
IReadOnlyDictionary<string, BuildingDef> buildings,
|
||||||
|
IReadOnlyDictionary<string, FloorDef> floors,
|
||||||
|
IReadOnlyDictionary<string, TerritoryDef> territories,
|
||||||
|
IReadOnlyDictionary<string, string> ru,
|
||||||
|
IReadOnlyDictionary<string, string> 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<string> PackIds { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ThingDef> Things { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, PositionDef> Positions { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, WorkDef> Works { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, RoomDef> Rooms { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, BuildingDef> Buildings { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, FloorDef> Floors { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
|
||||||
|
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _ru;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Positions the location panel should list for this node type. Only RoomDefs carry them today.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> PositionsFor(DefKind kind, string defName) =>
|
||||||
|
kind == DefKind.Room && Rooms.TryGetValue(defName, out var room) ? room.Positions : [];
|
||||||
|
|
||||||
|
/// <summary>Locale string for a def, walking <c>parent</c> when the key is missing. Falls back to <c>defName</c>.</summary>
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
public enum DefKind
|
||||||
|
{
|
||||||
|
Action,
|
||||||
|
Thing,
|
||||||
|
Position,
|
||||||
|
Work,
|
||||||
|
Room,
|
||||||
|
Building,
|
||||||
|
Floor,
|
||||||
|
Territory,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||||
|
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<string> 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<RoomSlot> Slots { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Positions { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Works { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuildingDef : Def;
|
||||||
|
|
||||||
|
public sealed class FloorDef : Def;
|
||||||
|
|
||||||
|
public sealed class TerritoryDef : Def;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Content</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>Warnings from the loader — last-wins collisions, not fatal errors.</summary>
|
||||||
|
public interface IContentLog
|
||||||
|
{
|
||||||
|
void Warning(string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops warnings. Tests that do not care about collisions use this.</summary>
|
||||||
|
public sealed class NullContentLog : IContentLog
|
||||||
|
{
|
||||||
|
public static NullContentLog Instance { get; } = new();
|
||||||
|
|
||||||
|
public void Warning(string message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>RFC 6901 pointers, enough for the three patch ops. Unknown tokens fail the catalog.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>JSON with comments and trailing commas — the format of every def, patch, locale and map file.</summary>
|
||||||
|
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<T>(JsonNode node)
|
||||||
|
{
|
||||||
|
var value = node.Deserialize<T>(SerializerOptions);
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"Could not read a {typeof(T).Name} from JSONC.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>One school's map instance: tree grouping plus a walkable graph of territory and rooms.</summary>
|
||||||
|
public sealed class MapLayout
|
||||||
|
{
|
||||||
|
public TerritoryNode? Territory { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<BuildingNode> Buildings { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<FloorNode> Floors { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<RoomNode> Rooms { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<MapLink> Links { get; init; } = [];
|
||||||
|
|
||||||
|
public static MapLayout Parse(string jsonc, string? source = null) =>
|
||||||
|
Jsonc.Deserialize<MapLayout>(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<SlotFill> 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks that a map instance is a connected undirected graph rooted at a yard, with every
|
||||||
|
/// <c>def</c> present and non-abstract in the school's catalog.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>(StringComparer.Ordinal);
|
||||||
|
AddId(ids, territory.Id, "territory");
|
||||||
|
|
||||||
|
var buildings = new Dictionary<string, BuildingNode>(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<string, FloorNode>(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<string, RoomNode>(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<string>(StringComparer.Ordinal) { territory.Id };
|
||||||
|
foreach (var roomId in rooms.Keys)
|
||||||
|
{
|
||||||
|
walkable.Add(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var adjacency = walkable.ToDictionary(id => id, _ => new List<string>(), 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<string>(StringComparer.Ordinal);
|
||||||
|
var queue = new Queue<string>();
|
||||||
|
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<string, string> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
internal sealed class PatchDocument
|
||||||
|
{
|
||||||
|
public required string Target { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PatchOp> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using HSchool.Content;
|
||||||
using HSchool.Protocol;
|
using HSchool.Protocol;
|
||||||
using HSchool.Server.Net;
|
using HSchool.Server.Net;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
@@ -15,6 +16,7 @@ internal sealed class GameLoopService(
|
|||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
GameMetrics metrics,
|
GameMetrics metrics,
|
||||||
SchoolStore store,
|
SchoolStore store,
|
||||||
|
ModContent mods,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILogger<GameLoopService> logger) : BackgroundService
|
ILogger<GameLoopService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -170,7 +172,7 @@ internal sealed class GameLoopService(
|
|||||||
var id = _nextId++;
|
var id = _nextId++;
|
||||||
store.WriteNextId(_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);
|
Track(worker);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -327,15 +329,25 @@ internal sealed class GameLoopService(
|
|||||||
save.GameTime,
|
save.GameTime,
|
||||||
save.Running,
|
save.Running,
|
||||||
save.SpeedIndex,
|
save.SpeedIndex,
|
||||||
isNew: false);
|
isNew: false,
|
||||||
Track(worker);
|
save.ModIds,
|
||||||
|
save.Map);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
}
|
|
||||||
|
|
||||||
if (_workers.Count > 0)
|
try
|
||||||
{
|
{
|
||||||
await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false);
|
await worker.Started.ConfigureAwait(false);
|
||||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
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);
|
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<string>? modIds,
|
||||||
|
MapLayout? map) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -360,10 +385,13 @@ internal sealed class GameLoopService(
|
|||||||
running,
|
running,
|
||||||
speedIndex,
|
speedIndex,
|
||||||
isNew,
|
isNew,
|
||||||
|
modIds,
|
||||||
|
map,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
store,
|
store,
|
||||||
|
mods,
|
||||||
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
|
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
|
||||||
|
|
||||||
private void Track(SchoolWorker worker)
|
private void Track(SchoolWorker worker)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads <c>mods/<id>/</c> from disk and hands the files to <see cref="CatalogLoader"/>.
|
||||||
|
/// Content itself never sees these paths.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ModContent
|
||||||
|
{
|
||||||
|
private readonly CatalogLoader _loader = new();
|
||||||
|
private readonly ILogger<ModContent> _logger;
|
||||||
|
|
||||||
|
public ModContent(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<ModContent> 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<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
|
||||||
|
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
|
||||||
|
|
||||||
|
public IReadOnlyList<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
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<string> 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<string> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SchoolContentUnavailableException : Exception
|
||||||
|
{
|
||||||
|
public SchoolContentUnavailableException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SchoolContentUnavailableException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using HSchool.Content;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace HSchool.Server.Game;
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
/// <summary>On-disk record of one school. Extra JSON fields are ignored so later slices can grow it.</summary>
|
/// <summary>On-disk record of one school. Extra JSON fields are ignored so later slices can grow it.</summary>
|
||||||
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<string>? ModIds { get; init; }
|
||||||
|
|
||||||
|
public MapLayout? Map { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||||
internal sealed record SchoolSaveIndex(int NextId);
|
internal sealed record SchoolSaveIndex(int NextId);
|
||||||
@@ -16,7 +34,7 @@ internal sealed record SchoolSaveIndex(int NextId);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SchoolStore
|
internal sealed class SchoolStore
|
||||||
{
|
{
|
||||||
public const int CurrentFormat = 1;
|
public const int CurrentFormat = 2;
|
||||||
|
|
||||||
private const string IndexFileName = "index.json";
|
private const string IndexFileName = "index.json";
|
||||||
|
|
||||||
@@ -99,7 +117,17 @@ internal sealed class SchoolStore
|
|||||||
continue;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using HSchool.Content;
|
||||||
using HSchool.Protocol;
|
using HSchool.Protocol;
|
||||||
using HSchool.Server.Net;
|
using HSchool.Server.Net;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
@@ -8,8 +9,8 @@ namespace HSchool.Server.Game;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's
|
/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's
|
||||||
/// save file. Awaits are resolved with <c>GetResult</c> so <see cref="School.Tick"/> stays on
|
/// frozen catalog, that school's save file. Awaits are resolved with <c>GetResult</c> so
|
||||||
/// this thread instead of hopping back onto the pool.
|
/// <see cref="School.Tick"/> stays on this thread instead of hopping back onto the pool.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SchoolWorker
|
internal sealed class SchoolWorker
|
||||||
{
|
{
|
||||||
@@ -19,12 +20,15 @@ internal sealed class SchoolWorker
|
|||||||
private readonly ClientRegistry _clients;
|
private readonly ClientRegistry _clients;
|
||||||
private readonly GameMetrics _metrics;
|
private readonly GameMetrics _metrics;
|
||||||
private readonly SchoolStore _store;
|
private readonly SchoolStore _store;
|
||||||
|
private readonly ModContent _mods;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly Channel<WorkerCommand> _mailbox = Channel.CreateUnbounded<WorkerCommand>(
|
private readonly Channel<WorkerCommand> _mailbox = Channel.CreateUnbounded<WorkerCommand>(
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||||
private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private readonly CancellationTokenSource _stopping = new();
|
private readonly CancellationTokenSource _stopping = new();
|
||||||
private readonly bool _isNew;
|
private readonly bool _isNew;
|
||||||
|
private readonly IReadOnlyList<string>? _modIds;
|
||||||
|
private readonly MapLayout? _savedMap;
|
||||||
|
|
||||||
private readonly int _id;
|
private readonly int _id;
|
||||||
private readonly string _name;
|
private readonly string _name;
|
||||||
@@ -44,10 +48,13 @@ internal sealed class SchoolWorker
|
|||||||
bool running,
|
bool running,
|
||||||
int speedIndex,
|
int speedIndex,
|
||||||
bool isNew,
|
bool isNew,
|
||||||
|
IReadOnlyList<string>? modIds,
|
||||||
|
MapLayout? savedMap,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
GameMetrics metrics,
|
GameMetrics metrics,
|
||||||
SchoolStore store,
|
SchoolStore store,
|
||||||
|
ModContent mods,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
_id = id;
|
_id = id;
|
||||||
@@ -56,10 +63,13 @@ internal sealed class SchoolWorker
|
|||||||
_running = running;
|
_running = running;
|
||||||
_speedIndex = speedIndex;
|
_speedIndex = speedIndex;
|
||||||
_isNew = isNew;
|
_isNew = isNew;
|
||||||
|
_modIds = modIds;
|
||||||
|
_savedMap = savedMap;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
_store = store;
|
_store = store;
|
||||||
|
_mods = mods;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||||
}
|
}
|
||||||
@@ -113,6 +123,11 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
RunLoop(_stopping.Token);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
||||||
@@ -122,9 +137,30 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
private void RunLoop(CancellationToken cancellationToken)
|
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
|
var school = _isNew
|
||||||
? School.Create(_id, _name, _time)
|
? School.Create(_id, _name, _time, catalog, map)
|
||||||
: School.Load(_id, _name, _time, _running, _speedIndex);
|
: School.Load(_id, _name, _time, _running, _speedIndex, catalog, map);
|
||||||
|
|
||||||
_school = school;
|
_school = school;
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
@@ -298,13 +334,17 @@ internal sealed class SchoolWorker
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_store.Save(new SchoolSave(
|
_store.Save(new SchoolSave
|
||||||
SchoolStore.CurrentFormat,
|
{
|
||||||
school.Id,
|
Format = SchoolStore.CurrentFormat,
|
||||||
school.Name,
|
Id = school.Id,
|
||||||
school.Clock.Time,
|
Name = school.Name,
|
||||||
school.Clock.IsRunning,
|
GameTime = school.Clock.Time,
|
||||||
school.Clock.SpeedIndex));
|
Running = school.Clock.IsRunning,
|
||||||
|
SpeedIndex = school.Clock.SpeedIndex,
|
||||||
|
ModIds = school.Catalog?.PackIds,
|
||||||
|
Map = school.Map,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BroadcastClock()
|
private void BroadcastClock()
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||||
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="mods\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ builder.Services
|
|||||||
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
||||||
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
|
.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.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.")
|
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
|
|||||||
builder.Services.AddSingleton<ClientRegistry>();
|
builder.Services.AddSingleton<ClientRegistry>();
|
||||||
builder.Services.AddSingleton<GameMetrics>();
|
builder.Services.AddSingleton<GameMetrics>();
|
||||||
builder.Services.AddSingleton<SchoolStore>();
|
builder.Services.AddSingleton<SchoolStore>();
|
||||||
|
builder.Services.AddSingleton<ModContent>();
|
||||||
builder.Services.AddSingleton<GameSocketHandler>();
|
builder.Services.AddSingleton<GameSocketHandler>();
|
||||||
builder.Services.AddSingleton<GameLoopService>();
|
builder.Services.AddSingleton<GameLoopService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"GameMinutesPerRealSecond": 5,
|
"GameMinutesPerRealSecond": 5,
|
||||||
"DefaultStartDate": "2012-04-03T06:00:00",
|
"DefaultStartDate": "2012-04-03T06:00:00",
|
||||||
"SavesDirectory": "saves",
|
"SavesDirectory": "saves",
|
||||||
|
"ModsDirectory": "mods",
|
||||||
"SaveIntervalSeconds": 30
|
"SaveIntervalSeconds": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "Sit" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "MainBuilding" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "StandardFloor" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "Principal" }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
|
||||||
|
{ "defName": "Corridor" }
|
||||||
@@ -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"],
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "SchoolYard" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "Chair", "actions": ["Sit"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "Desk" }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defName": "DirectorsChair", "parent": "Chair" }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{ "defName": "PrincipalOfficeWork" },
|
||||||
|
{ "defName": "TeachLesson" },
|
||||||
|
{ "defName": "WalkSchool" },
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"Sit": "Сесть",
|
||||||
|
"Chair": "Стул",
|
||||||
|
"DirectorsChair": "Кресло директора",
|
||||||
|
"Desk": "Стол",
|
||||||
|
"Principal": "Директор",
|
||||||
|
"PrincipalOfficeWork": "Работа директора",
|
||||||
|
"TeachLesson": "Урок",
|
||||||
|
"WalkSchool": "Обход школы",
|
||||||
|
"SchoolYard": "Двор",
|
||||||
|
"MainBuilding": "Главный корпус",
|
||||||
|
"StandardFloor": "Этажи",
|
||||||
|
"Corridor": "Коридор",
|
||||||
|
"PrincipalsOffice": "Кабинет директора",
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -9,4 +9,8 @@
|
|||||||
<PackageReference Include="Arch" />
|
<PackageReference Include="Arch" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Arch.Core;
|
using Arch.Core;
|
||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
namespace HSchool.Simulation;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One save: a name, a calendar and the ECS world that will hold everything the school is made of.
|
/// One save: a name, a calendar, a frozen def catalog, a map instance, and the ECS world that will
|
||||||
/// The world is empty for now — pupils, rooms and staff land in it as the game grows — but it is
|
/// hold everything the school is made of. The world is empty for now — pupils, rooms and staff
|
||||||
/// created and destroyed with the school so ownership is never in question.
|
/// land in it as the game grows — but it is created and destroyed with the school so ownership is
|
||||||
|
/// never in question.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class School : IDisposable
|
public sealed class School : IDisposable
|
||||||
{
|
{
|
||||||
@@ -14,21 +16,36 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
private bool _disposed;
|
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;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
Clock = new GameClock(startDate);
|
Clock = new GameClock(startDate);
|
||||||
|
Catalog = catalog;
|
||||||
|
Map = map;
|
||||||
World = World.Create();
|
World = World.Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
|
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
|
||||||
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);
|
||||||
|
|
||||||
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
|
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
|
||||||
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.IsRunning = running;
|
||||||
school.Clock.SpeedIndex = speedIndex;
|
school.Clock.SpeedIndex = speedIndex;
|
||||||
return school;
|
return school;
|
||||||
@@ -40,6 +57,12 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
public GameClock Clock { get; }
|
public GameClock Clock { get; }
|
||||||
|
|
||||||
|
/// <summary>Frozen at create/load. Null only in clock-only unit tests.</summary>
|
||||||
|
public DefCatalog? Catalog { get; }
|
||||||
|
|
||||||
|
/// <summary>The school's map instance. Null only in clock-only unit tests.</summary>
|
||||||
|
public MapLayout? Map { get; }
|
||||||
|
|
||||||
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
||||||
public World World { get; }
|
public World World { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ public sealed class SimulationOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string SavesDirectory { get; set; } = "saves";
|
public string SavesDirectory { get; set; } = "saves";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directory of mod packs (<c>core</c> and optional add-ons). Relative paths are resolved
|
||||||
|
/// against the content root.
|
||||||
|
/// </summary>
|
||||||
|
public string ModsDirectory { get; set; } = "mods";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How often a running school writes its clock to disk. Create, pause, speed and shutdown
|
/// How often a running school writes its clock to disk. Create, pause, speed and shutdown
|
||||||
/// write immediately; the tick itself never does.
|
/// write immediately; the tick itself never does.
|
||||||
|
|||||||
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Content.Tests</RootNamespace>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
|
||||||
|
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MapValidationException>(() => 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<MapValidationException>(() => 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<MapValidationException>(() => 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<MapValidationException>(() => 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<MapValidationException>(() => 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<MapValidationException>(() => 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<MapValidationException>(() => 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<ContentDocument> 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<MapLink>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
internal sealed class RecordingLog : IContentLog
|
||||||
|
{
|
||||||
|
public List<string> 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<ContentDocument> FromDirectory(string packId, string packRoot)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
namespace HSchool.Simulation.Tests;
|
namespace HSchool.Simulation.Tests;
|
||||||
|
|
||||||
public class SchoolTests
|
public class SchoolTests
|
||||||
@@ -39,4 +41,36 @@ public class SchoolTests
|
|||||||
|
|
||||||
Assert.Equal(Start.AddMinutes(12), school.Clock.Time);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user