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