From 263c94c55d0b6ef76ac3e3e63865e31e2e29ce92 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 00:20:36 +0300 Subject: [PATCH 1/2] Give packs an identity so create can refuse missing deps and load in a stable order. Co-authored-by: Cursor --- docs/design/foundation.md | 17 +- docs/phases/22-mod-identity.md | 32 ++-- docs/protocol.md | 27 ++- src/HSchool.Client/src/i18n/strings.ts | 4 + src/HSchool.Client/src/net/api.ts | 12 +- .../src/ui/createSchoolDialog.ts | 8 +- src/HSchool.Content/CatalogLoader.cs | 36 ++++ .../PackDependencyException.cs | 32 ++++ src/HSchool.Content/PackLoadOrder.cs | 93 +++++++++ src/HSchool.Content/PackManifest.cs | 126 +++++++++++++ src/HSchool.Content/PackPaths.cs | 7 + src/HSchool.Server/Api/ModEndpoints.cs | 44 ++++- src/HSchool.Server/Api/SchoolEndpoints.cs | 30 ++- src/HSchool.Server/Game/GameLoopService.cs | 25 ++- src/HSchool.Server/Game/ModContent.cs | 75 +++++++- .../Game/SchoolCreationOutcome.cs | 5 +- src/HSchool.Server/Game/SchoolState.cs | 8 +- src/HSchool.Server/Game/SchoolWorker.cs | 6 +- src/HSchool.Server/Program.cs | 3 + .../mods/core/localizations/en.jsonc | 1 + .../mods/core/localizations/ru.jsonc | 1 + src/HSchool.Server/mods/core/pack.jsonc | 4 + src/HSchool.Simulation/SchoolCreationError.cs | 2 + tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 177 +++++++++++++++++- .../CatalogLoaderTests.cs | 14 ++ tests/HSchool.Content.Tests/PackDocuments.cs | 3 + .../PackIdentityTests.cs | 114 +++++++++++ .../HSchool.Content.Tests/VanillaCoreTests.cs | 1 + 28 files changed, 851 insertions(+), 56 deletions(-) create mode 100644 src/HSchool.Content/PackDependencyException.cs create mode 100644 src/HSchool.Content/PackLoadOrder.cs create mode 100644 src/HSchool.Content/PackManifest.cs create mode 100644 src/HSchool.Server/mods/core/pack.jsonc create mode 100644 tests/HSchool.Content.Tests/PackIdentityTests.cs diff --git a/docs/design/foundation.md b/docs/design/foundation.md index f6fd84c..19b5564 100644 --- a/docs/design/foundation.md +++ b/docs/design/foundation.md @@ -9,12 +9,11 @@ ## Почему сейчас -**Моды существуют на бумаге.** В `mods/` лежит один `core`. Last-wins, патчи и порядок загрузки -проверяются синтетическими документами в памяти, а путь «игрок выбрал мод» не проверяется вообще — -подставить нечего. Пак не имеет удостоверения: `GET /api/mods` отдаёт `{id, required}`, и в диалоге -создания игрок видит имя папки. Зависимостей между паками нет, поэтому мебельный набор, который -патчит чужой def, может быть выбран без того, кого он патчит, — и школа не соберётся с невнятной -ошибкой каталога. +**Моды существуют на бумаге — остался проверяемый путь.** Удостоверение пака уже есть: +`pack.jsonc` (версия и `requires`), название в локалях по id, `GET /api/mods?lang=` отдаёт +подпись, создание отказывает во внятном коде, если зависимости нет или они замкнуты в цикл. +Порядок загрузки сервер выстраивает сам и кладёт в сейв. В `mods/` по-прежнему лежит один +`core`: last-wins, патчи и дорога «игрок выбрал мод» ждут настоящую папку — это фаза 23. **Числа поведения живут в коде вопреки [`ai.md`](ai.md).** Там записано: «Числа поведения — отдельный деф правил, как `StaffingDef` у штата». В `BehaviorDef` уехали порог нужды, скорость @@ -100,9 +99,9 @@ нанимает второго, ничего не меняется, и понять почему неоткуда. В строку непокрытого предмета добавляется, сколько человек его не вытягивают. -**Def без подписи.** Загрузчик молча подставляет `defName`, когда ключа локали нет. Для `core` это -ловит тест на полноту, для мода — ничего. Загрузчик начинает писать предупреждение в лог: мод-автор -видит дыру сразу, а не по кривой подписи в дереве. +**Def без подписи.** Загрузчик пишет предупреждение в лог, когда у конкретного неабстрактного def +нет ключа ни в `ru`, ни в `en`. Для `core` это ловит тест на полноту; для мода автор видит дыру +сразу, а не по кривой подписи в дереве. Каталог при этом собирается — подставляется `defName`. ## Сид школы — свой, а не производный diff --git a/docs/phases/22-mod-identity.md b/docs/phases/22-mod-identity.md index acc4bb6..0c2d43b 100644 --- a/docs/phases/22-mod-identity.md +++ b/docs/phases/22-mod-identity.md @@ -11,31 +11,31 @@ ## Задачи -- [ ] `pack.jsonc` в папке пака: `version` строкой, `requires` списком id. Файла нет — пак +- [x] `pack.jsonc` в папке пака: `version` строкой, `requires` списком id. Файла нет — пак по-прежнему валиден: id вместо названия, версия пустая, зависимостей нет -- [ ] Название пака — ключ по его id в его же `localizations/.jsonc`; второго способа +- [x] Название пака — ключ по его id в его же `localizations/.jsonc`; второго способа называть вещи не заводить -- [ ] `GET /api/mods` принимает `?lang=ru|en` и отдаёт `label`, `version` и `requires` рядом с +- [x] `GET /api/mods` принимает `?lang=ru|en` и отдаёт `label`, `version` и `requires` рядом с `id` и `required` -- [ ] У `core` такой же `pack.jsonc` и такое же название в локалях -- [ ] Создание школы проверяет, что каждая зависимость выбрана; нет — `400` с кодом и id того, +- [x] У `core` такой же `pack.jsonc` и такое же название в локалях +- [x] Создание школы проверяет, что каждая зависимость выбрана; нет — `400` с кодом и id того, кого не хватает -- [ ] Порядок загрузки выстраивает сервер: устойчивая топологическая сортировка поверх порядка +- [x] Порядок загрузки выстраивает сервер: устойчивая топологическая сортировка поверх порядка игрока, `core` всегда первый. Цикл зависимостей — отказ -- [ ] Разрешённый порядок виден: пишется в лог при старте школы и возвращается в ответе создания -- [ ] Сейв хранит **разрешённый** порядок паков, чтобы школа поднималась тем же каталогом -- [ ] Загрузчик пишет предупреждение, когда у конкретного def нет подписи в локали пака -- [ ] `docs/protocol.md` и [`../design/foundation.md`](../design/foundation.md) правятся тем же +- [x] Разрешённый порядок виден: пишется в лог при старте школы и возвращается в ответе создания +- [x] Сейв хранит **разрешённый** порядок паков, чтобы школа поднималась тем же каталогом +- [x] Загрузчик пишет предупреждение, когда у конкретного def нет подписи в локали пака +- [x] `docs/protocol.md` и [`../design/foundation.md`](../design/foundation.md) правятся тем же коммитом, что и обработчики ## Тесты, без которых фаза не закрыта -- [ ] Пак без `pack.jsonc` виден в списке, id стоит вместо названия -- [ ] Название приходит на языке запроса, у `core` тоже -- [ ] Пак с невыбранной зависимостью не создаёт школу; в ответе видно, кого не хватает -- [ ] Зависимость, выбранная после зависимого, всё равно грузится раньше -- [ ] Цикл зависимостей — отказ, а не зависание -- [ ] Def без подписи даёт предупреждение, но не роняет каталог +- [x] Пак без `pack.jsonc` виден в списке, id стоит вместо названия +- [x] Название приходит на языке запроса, у `core` тоже +- [x] Пак с невыбранной зависимостью не создаёт школу; в ответе видно, кого не хватает +- [x] Зависимость, выбранная после зависимого, всё равно грузится раньше +- [x] Цикл зависимостей — отказ, а не зависание +- [x] Def без подписи даёт предупреждение, но не роняет каталог ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 9603020..a7bb3e8 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -40,7 +40,7 @@ from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a c "gameMinutesPerRealSecond": 5, "schoolWeekDays": 5, "schools": [ - { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 } + { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"] } ] } ``` @@ -53,20 +53,27 @@ Optional `?lang=en` draws from the English word list (`Northern Academy`); any o none, stays Russian. The client sends the active UI language. Names the player types are not translated — they are saved as written. -### `GET /api/mods` +### `GET /api/mods?lang=ru|en` Folders under the server's `mods/` directory. `core` is always first and `required: true`; other -packs can be switched off in the create dialog. +packs can be switched off in the create dialog. `lang` is the same value Hello carries — not +`Accept-Language`. Anything other than `en` is Russian. + +Each pack carries a human label, a version string and the ids it `requires`. The label is the pack +id looked up in that pack's own `localizations/.jsonc`. A folder without `pack.jsonc` is +still a pack: the id stands in for the name, `version` is empty, `requires` is empty. ```json -{ "mods": [{ "id": "core", "required": true }] } +{ "mods": [{ "id": "core", "required": true, "label": "Базовая игра", "version": "1.0", "requires": [] }] } ``` ### `GET /api/catalog?lang=ru|en&mods=addon1,addon2` Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/default.jsonc` for -`core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated -list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. +`core` plus the listed extras. The server always prepends `core` and then reorders extras so +`requires` load first, same as create. `mods` is a comma-separated list of extra pack ids; omit +it for vanilla. Unknown extras return `400` `unknown-mod`. A selected pack whose dependency was +not listed returns `400` `missing-mod`; a cycle returns `400` `mod-cycle`. Room defs that are homerooms carry `homeroom`, `seatThing` and `defaultSeats` instead of a slot table. A map classroom stores `seats` — how many of that thing occupy the room. Capacity is @@ -114,8 +121,10 @@ Body: } ``` -`modIds` are extras; the server always prepends `core`. Omit `map` (or send `null`) to use that -pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. +`modIds` are extras; the server always prepends `core`, then **reorders** the selection so each +pack's `requires` load first (stable topological sort over the player's order). The resolved +order is returned as `modIds` on the created school and written to the save, so a restart loads +the same catalog. Omit `map` (or send `null`) to use that pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. `nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla: `Slavic`). Unknown ids return `400` `unknown-name-set`. `nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick @@ -128,6 +137,8 @@ one from the school seed. An id that is not in the set returns `400` `unknown-na | `400` `invalid-start-date` | Outside 1900–2999. | | `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. | | `400` `unknown-mod` | An extra pack id is missing under `mods/`. | +| `400` `missing-mod` | A selected pack `requires` an id that was not selected. `missing` is that id. | +| `400` `mod-cycle` | Selected packs require each other in a cycle. | | `400` `invalid-catalog` | The selected packs could not be loaded. | | `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. | | `400` `unknown-native-language` | `nativeLanguage` is not in that name set's `nativeLanguages`. | diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index a9edc19..0a625bf 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -40,6 +40,8 @@ const ru = { errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.', errorUnknownMod: 'Выбранный мод не найден.', + errorMissingMod: 'Не выбран обязательный мод «{id}».', + errorModCycle: 'Выбранные моды зависят друг от друга по кругу.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorUnknownNameSet: 'Выбранный набор имён не найден.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.', @@ -251,6 +253,8 @@ const en: Messages = { errorInvalidStartDate: 'The start date is outside the allowed range.', errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.', errorUnknownMod: 'A selected mod is missing.', + errorMissingMod: 'Required mod “{id}” is not selected.', + errorModCycle: 'The selected mods depend on each other in a cycle.', errorInvalidCatalog: 'The selected packs could not be loaded.', errorUnknownNameSet: 'The selected name set is not in the catalog.', errorUnknownNativeLanguage: 'The selected native language is not in that name set.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 6710575..6a4bea4 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -10,6 +10,7 @@ export interface School { readonly gameTime: string; readonly running: boolean; readonly speedIndex: number; + readonly modIds?: readonly string[]; } export interface SchoolsResponse { @@ -30,6 +31,7 @@ export class ApiError extends Error { readonly payroll?: number, readonly remaining?: number, readonly attempted?: number, + readonly missing?: string, ) { super(message); } @@ -75,6 +77,9 @@ export async function createSchool( export interface ModInfo { readonly id: string; readonly required: boolean; + readonly label: string; + readonly version: string; + readonly requires: readonly string[]; } export interface DefInfo { @@ -167,8 +172,9 @@ export interface CatalogResponse { readonly holidays: readonly HolidayInfo[]; } -export async function fetchMods(): Promise { - const response = await request<{ mods: readonly ModInfo[] }>('/api/mods'); +export async function fetchMods(lang: string): Promise { + const query = new URLSearchParams({ lang }); + const response = await request<{ mods: readonly ModInfo[] }>(`/api/mods?${query.toString()}`); return response.mods; } @@ -528,6 +534,7 @@ async function toApiError(response: Response): Promise { payroll?: number; remaining?: number; attempted?: number; + missing?: string; }; return new ApiError( response.status, @@ -537,6 +544,7 @@ async function toApiError(response: Response): Promise { problem.payroll, problem.remaining, problem.attempted, + problem.missing, ); } catch { return new ApiError(response.status, 'unknown', response.statusText); diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 6ff892b..07af853 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -194,7 +194,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise => { let packs; try { - packs = await fetchMods(); + packs = await fetchMods(getLocale()); } catch { showError(t('catalogLoadFailed')); return; @@ -223,7 +223,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise ConcreteDefs(DefCatalog catalog) + { + return Enumerate(catalog.Actions.Values) + .Concat(Enumerate(catalog.Things.Values)) + .Concat(Enumerate(catalog.Positions.Values)) + .Concat(Enumerate(catalog.Works.Values)) + .Concat(Enumerate(catalog.Rooms.Values)) + .Concat(Enumerate(catalog.Buildings.Values)) + .Concat(Enumerate(catalog.Floors.Values)) + .Concat(Enumerate(catalog.Territories.Values)) + .Concat(Enumerate(catalog.Skills.Values)) + .Concat(Enumerate(catalog.Traits.Values)) + .Concat(Enumerate(catalog.BodyAttributes.Values)) + .Concat(Enumerate(catalog.Needs.Values)) + .Concat(Enumerate(catalog.NameSets.Values)) + .Concat(Enumerate(catalog.Subjects.Values)) + .Concat(Enumerate(catalog.Staffing.Values)) + .Concat(Enumerate(catalog.DayFrames.Values)) + .Concat(Enumerate(catalog.Holidays.Values)) + .Concat(Enumerate(catalog.Behavior.Values)); + + static IEnumerable Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract); + } + private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source); } diff --git a/src/HSchool.Content/PackDependencyException.cs b/src/HSchool.Content/PackDependencyException.cs new file mode 100644 index 0000000..8a90a3b --- /dev/null +++ b/src/HSchool.Content/PackDependencyException.cs @@ -0,0 +1,32 @@ +namespace HSchool.Content; + +/// +/// Selected packs cannot be ordered: a required pack is missing, or they require each other. +/// Distinct from so the create dialog can name the pack. +/// +public sealed class PackDependencyException : Exception +{ + public const string MissingCode = "missing-mod"; + + public const string CycleCode = "mod-cycle"; + + private PackDependencyException(string message, string code, string? missingPackId) + : base(message) + { + Code = code; + MissingPackId = missingPackId; + } + + public string Code { get; } + + public string? MissingPackId { get; } + + public static PackDependencyException Missing(string missingPackId, string requiredBy) => + new( + $"Pack '{requiredBy}' requires '{missingPackId}', which was not selected.", + MissingCode, + missingPackId); + + public static PackDependencyException Cycle() => + new("Selected packs have a cyclic dependency.", CycleCode, missingPackId: null); +} diff --git a/src/HSchool.Content/PackLoadOrder.cs b/src/HSchool.Content/PackLoadOrder.cs new file mode 100644 index 0000000..2e6d43e --- /dev/null +++ b/src/HSchool.Content/PackLoadOrder.cs @@ -0,0 +1,93 @@ +namespace HSchool.Content; + +/// +/// Stable topological sort of selected packs. Player order is kept wherever it does not +/// contradict requires. core is expected to already be first in +/// . +/// +public static class PackLoadOrder +{ + public static IReadOnlyList Resolve( + IReadOnlyList selected, + IReadOnlyDictionary manifests) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < selected.Count; i++) + { + index[selected[i]] = i; + } + + var indegree = new Dictionary(StringComparer.OrdinalIgnoreCase); + var outgoing = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var packId in selected) + { + indegree[packId] = 0; + outgoing[packId] = []; + } + + foreach (var packId in selected) + { + var requires = manifests.TryGetValue(packId, out var manifest) + ? manifest.Requires + : PackManifest.Empty.Requires; + + foreach (var dependency in requires) + { + if (!indegree.ContainsKey(dependency)) + { + throw PackDependencyException.Missing(dependency, packId); + } + + outgoing[dependency].Add(packId); + indegree[packId]++; + } + } + + var ready = new SortedSet(); + for (var i = 0; i < selected.Count; i++) + { + if (indegree[selected[i]] == 0) + { + ready.Add(i); + } + } + + var ordered = new List(selected.Count); + while (ready.Count > 0) + { + var next = ready.Min; + ready.Remove(next); + var packId = selected[next]; + ordered.Add(packId); + + foreach (var dependent in outgoing[packId].OrderBy(id => index[id])) + { + indegree[dependent]--; + if (indegree[dependent] == 0) + { + ready.Add(index[dependent]); + } + } + } + + if (ordered.Count != selected.Count) + { + throw PackDependencyException.Cycle(); + } + + return ordered; + } + + public static IReadOnlyDictionary ManifestsFrom( + IReadOnlyList packIds, + IReadOnlyList documents) + { + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var packId in packIds) + { + manifests[packId] = PackManifest.FromDocuments(packId, documents); + } + + return manifests; + } +} diff --git a/src/HSchool.Content/PackManifest.cs b/src/HSchool.Content/PackManifest.cs new file mode 100644 index 0000000..c2c46e4 --- /dev/null +++ b/src/HSchool.Content/PackManifest.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Nodes; + +namespace HSchool.Content; + +/// +/// Identity of one pack from pack.jsonc. A folder without that file is still a pack: +/// empty version, no dependencies, id used as the label until a locale supplies one. +/// +public sealed record PackManifest(string Version, IReadOnlyList Requires) +{ + public static PackManifest Empty { get; } = new(string.Empty, []); + + public static PackManifest Parse(string packId, string text) + { + var source = $"{packId}:pack.jsonc"; + var node = Jsonc.Parse(text, source); + if (node is not JsonObject obj) + { + throw new ContentLoadException($"{source} must be an object."); + } + + var version = ReadVersion(obj, source); + var requires = ReadRequires(obj, source); + return new PackManifest(version, requires); + } + + public static PackManifest FromDocuments(string packId, IReadOnlyList documents) + { + foreach (var document in documents) + { + if (document.PackId == packId && PackPaths.IsManifest(document.RelativePath)) + { + return Parse(packId, document.Text); + } + } + + return Empty; + } + + /// + /// Pack title from that pack's own locale table, keyed by pack id. Missing key → the id. + /// + public static string Label(string packId, string locale, IReadOnlyList documents) + { + foreach (var document in documents) + { + if (document.PackId != packId + || !PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language) + || !language.Equals(locale, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var table = ReadLocaleTable(document.Text, $"{packId}:{document.RelativePath}"); + return table.TryGetValue(packId, out var label) ? label : packId; + } + + return packId; + } + + public static IReadOnlyDictionary ReadLocaleTable(string text, string source) + { + var node = Jsonc.Parse(text, source); + if (node is not JsonObject obj) + { + throw new ContentLoadException($"Localization in {source} must be an object of strings."); + } + + var table = new Dictionary(StringComparer.Ordinal); + foreach (var property in obj) + { + if (property.Value is not JsonValue value || !value.TryGetValue(out var label)) + { + throw new ContentLoadException($"Localization key '{property.Key}' in {source} is not a string."); + } + + table[property.Key] = label; + } + + return table; + } + + private static string ReadVersion(JsonObject obj, string source) + { + if (obj["version"] is null) + { + return string.Empty; + } + + if (obj["version"] is JsonValue value && value.TryGetValue(out var version)) + { + return version ?? string.Empty; + } + + throw new ContentLoadException($"{source} version must be a string."); + } + + private static IReadOnlyList ReadRequires(JsonObject obj, string source) + { + if (obj["requires"] is null) + { + return []; + } + + if (obj["requires"] is not JsonArray array) + { + throw new ContentLoadException($"{source} requires must be an array of pack ids."); + } + + var requires = new List(); + foreach (var item in array) + { + if (item is not JsonValue value || !value.TryGetValue(out var packId) || string.IsNullOrWhiteSpace(packId)) + { + throw new ContentLoadException($"{source} requires entries must be non-empty strings."); + } + + if (!requires.Contains(packId, StringComparer.OrdinalIgnoreCase)) + { + requires.Add(packId); + } + } + + return requires; + } +} diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index 3376db6..ae043df 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -58,6 +58,13 @@ internal static class PackPaths Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase) || Normalize(relativePath).Equals("maps/default.json", StringComparison.OrdinalIgnoreCase); + public static bool IsManifest(string relativePath) + { + var path = Normalize(relativePath); + return path.Equals("pack.jsonc", StringComparison.OrdinalIgnoreCase) + || path.Equals("pack.json", StringComparison.OrdinalIgnoreCase); + } + private static bool TryMapFolder(string folder, out DefKind kind) { switch (folder.ToLowerInvariant()) diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 3c35325..038ff60 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -11,8 +11,16 @@ internal static class ModEndpoints { public static void MapModEndpoints(this IEndpointRouteBuilder builder) { - builder.MapGet("/api/mods", (ModContent mods) => - new ModsResponse(mods.ListPacks().Select(pack => new ModInfoResponse(pack.Id, pack.Required)).ToArray())) + builder.MapGet("/api/mods", (string? lang, ModContent mods) => + { + var locale = string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru"; + return new ModsResponse(mods.ListPacks(locale).Select(pack => new ModInfoResponse( + pack.Id, + pack.Required, + pack.Label, + pack.Version, + pack.Requires)).ToArray()); + }) .WithName("GetMods"); builder.MapGet("/api/catalog", (string? lang, string? mods, ModContent content) => @@ -31,7 +39,19 @@ internal static class ModEndpoints return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The core pack is missing."); } - var packIds = content.NormalizePackIds(extras); + IReadOnlyList packIds; + try + { + packIds = content.ResolveSelectedPacks(extras); + } + catch (PackDependencyException ex) + { + return PackProblem(ex); + } + catch (ContentLoadException ex) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message); + } DefCatalog catalog; MapLayout map; try @@ -69,6 +89,17 @@ internal static class ModEndpoints return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } + private static IResult PackProblem(PackDependencyException ex) => + Results.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status400BadRequest, + title: ex.Code, + extensions: new Dictionary + { + ["code"] = ex.Code, + ["missing"] = ex.MissingPackId, + }); + private static IResult Problem(int statusCode, string code, string detail) => Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary { @@ -78,7 +109,12 @@ internal static class ModEndpoints internal sealed record ModsResponse(IReadOnlyList Mods); -internal sealed record ModInfoResponse(string Id, bool Required); +internal sealed record ModInfoResponse( + string Id, + bool Required, + string Label, + string Version, + IReadOnlyList Requires); internal sealed record CatalogResponse( IReadOnlyList Territories, diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 3083437..c070b38 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -80,6 +80,14 @@ internal static class SchoolEndpoints Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."), SchoolCreationError.UnknownNativeLanguage => Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."), + SchoolCreationError.MissingMod => + Problem( + StatusCodes.Status400BadRequest, + "missing-mod", + $"Mod '{outcome.MissingPackId}' is required but was not selected.", + missing: outcome.MissingPackId), + SchoolCreationError.ModCycle => + Problem(StatusCodes.Status400BadRequest, "mod-cycle", "Selected mods have a cyclic dependency."), _ => Results.Problem("Unknown error."), }; }) @@ -468,7 +476,12 @@ internal static class SchoolEndpoints return true; } - private static IResult Problem(int statusCode, string code, string detail, StaffingOutcome? staffing = null) + private static IResult Problem( + int statusCode, + string code, + string detail, + StaffingOutcome? staffing = null, + string? missing = null) { var extensions = new Dictionary { ["code"] = code }; if (staffing is not null) @@ -479,6 +492,11 @@ internal static class SchoolEndpoints extensions["attempted"] = staffing.Attempted; } + if (missing is not null) + { + extensions["missing"] = missing; + } + return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions); } } @@ -492,10 +510,16 @@ internal sealed record CreateSchoolRequest( string? NameSetId, string? NativeLanguage); -internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) +internal sealed record SchoolResponse( + int Id, + string Name, + DateTime GameTime, + bool Running, + byte SpeedIndex, + IReadOnlyList ModIds) { public static SchoolResponse From(SchoolState school) => - new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex); + new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds); } /// Everything the main menu needs in one request. diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index a5e41bb..8b75908 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -303,7 +303,30 @@ internal sealed class GameLoopService( } } - var packIds = mods.NormalizePackIds(extras); + IReadOnlyList packIds; + try + { + packIds = mods.ResolveSelectedPacks(extras); + } + catch (PackDependencyException ex) when (ex.Code == PackDependencyException.MissingCode) + { + command.Result.TrySetResult(new SchoolCreationOutcome( + null, + SchoolCreationError.MissingMod, + ex.MissingPackId)); + return; + } + catch (PackDependencyException) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ModCycle)); + return; + } + catch (ContentLoadException) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); + return; + } + if (!mods.PackExists(CatalogLoader.CorePackId)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); diff --git a/src/HSchool.Server/Game/ModContent.cs b/src/HSchool.Server/Game/ModContent.cs index c3eba4e..db61b98 100644 --- a/src/HSchool.Server/Game/ModContent.cs +++ b/src/HSchool.Server/Game/ModContent.cs @@ -51,9 +51,9 @@ internal sealed class ModContent return true; } - public IReadOnlyList ListPacks() + public IReadOnlyList ListPacks(string locale) { - var packs = new List { new(CatalogLoader.CorePackId, Required: true) }; + var packs = new List { Describe(CatalogLoader.CorePackId, required: true, locale) }; if (!Directory.Exists(Root)) { return packs; @@ -67,7 +67,7 @@ internal sealed class ModContent continue; } - packs.Add(new ModPackInfo(id, Required: false)); + packs.Add(Describe(id, required: false, locale)); } return packs; @@ -76,6 +76,30 @@ internal sealed class ModContent public IReadOnlyList NormalizePackIds(IReadOnlyList? extraModIds) => CatalogLoader.NormalizePackOrder(extraModIds ?? []); + /// + /// Player extras plus core, reordered so each pack's requires load first. + /// Missing dependencies and cycles throw . + /// + public IReadOnlyList ResolveSelectedPacks(IReadOnlyList? extraModIds) + { + var selected = NormalizePackIds(extraModIds); + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var packId in selected) + { + manifests[packId] = ReadManifest(packId); + } + + return PackLoadOrder.Resolve(selected, manifests); + } + + public PackManifest ReadManifest(string packId) + { + var jsonc = Path.Combine(PackPath(packId), "pack.jsonc"); + var json = Path.Combine(PackPath(packId), "pack.json"); + var path = File.Exists(jsonc) ? jsonc : File.Exists(json) ? json : null; + return path is null ? PackManifest.Empty : PackManifest.Parse(packId, File.ReadAllText(path)); + } + public IReadOnlyList ReadDocuments(IReadOnlyList packIds) { var documents = new List(); @@ -136,7 +160,50 @@ internal sealed class ModContent public DefCatalog LoadCatalog(IReadOnlyList packIds) => LoadCatalog(packIds, _logger); + private ModPackInfo Describe(string packId, bool required, string locale) + { + PackManifest manifest; + try + { + manifest = PackExists(packId) ? ReadManifest(packId) : PackManifest.Empty; + } + catch (ContentLoadException ex) + { + _logger.LogWarning(ex, "Could not read pack.jsonc for {PackId}.", packId); + manifest = PackManifest.Empty; + } + + return new ModPackInfo(packId, required, ReadPackLabel(packId, locale), manifest.Version, manifest.Requires); + } + + private string ReadPackLabel(string packId, string locale) + { + var jsonc = Path.Combine(PackPath(packId), "localizations", $"{locale}.jsonc"); + var json = Path.Combine(PackPath(packId), "localizations", $"{locale}.json"); + var path = File.Exists(jsonc) ? jsonc : File.Exists(json) ? json : null; + if (path is null) + { + return packId; + } + + try + { + var table = PackManifest.ReadLocaleTable(File.ReadAllText(path), $"{packId}:localizations/{locale}"); + return table.TryGetValue(packId, out var label) ? label : packId; + } + catch (ContentLoadException ex) + { + _logger.LogWarning(ex, "Could not read pack label for {PackId}.", packId); + return packId; + } + } + private string PackPath(string packId) => Path.Combine(Root, packId); } -internal sealed record ModPackInfo(string Id, bool Required); +internal sealed record ModPackInfo( + string Id, + bool Required, + string Label, + string Version, + IReadOnlyList Requires); diff --git a/src/HSchool.Server/Game/SchoolCreationOutcome.cs b/src/HSchool.Server/Game/SchoolCreationOutcome.cs index 096c487..c72d1ba 100644 --- a/src/HSchool.Server/Game/SchoolCreationOutcome.cs +++ b/src/HSchool.Server/Game/SchoolCreationOutcome.cs @@ -3,7 +3,10 @@ using HSchool.Simulation; namespace HSchool.Server.Game; /// What the supervisor reports back after trying to create a school. -internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error) +internal readonly record struct SchoolCreationOutcome( + SchoolState? School, + SchoolCreationError Error, + string? MissingPackId = null) { public bool Succeeded => Error == SchoolCreationError.None && School is not null; } diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs index ae68a4a..0bb00de 100644 --- a/src/HSchool.Server/Game/SchoolState.cs +++ b/src/HSchool.Server/Game/SchoolState.cs @@ -4,7 +4,13 @@ namespace HSchool.Server.Game; /// Immutable copy of a school, safe to hand to request threads. The live School object /// never leaves its worker thread. /// -internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); +internal sealed record SchoolState( + int Id, + string Name, + DateTime GameTime, + bool Running, + byte SpeedIndex, + IReadOnlyList ModIds); /// Everything the main menu needs in one read. internal sealed record SchoolsState(int MaxSchools, IReadOnlyList Schools); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 972b94e..75c52d8 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -94,7 +94,7 @@ internal sealed class SchoolWorker _mods = mods; _onFailed = onFailed; _logger = logger; - _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex); + _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? []); } public int Id => _id; @@ -203,6 +203,7 @@ internal sealed class SchoolWorker private void RunLoop(CancellationToken cancellationToken) { var packIds = _mods.NormalizePackIds(_modIds); + _logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds)); foreach (var packId in packIds) { if (!_mods.PackExists(packId)) @@ -577,7 +578,8 @@ internal sealed class SchoolWorker school.Name, school.Clock.Time, school.Clock.IsRunning, - (byte)school.Clock.SpeedIndex)); + (byte)school.Clock.SpeedIndex, + school.Catalog?.PackIds ?? _modIds ?? [])); Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _timetableSnapshot, school.Timetable); diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 4965956..25880d1 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -73,6 +73,9 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false)) app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath })) .WithName("GetSavesDirectory"); + + app.MapGet("/api/dev/mods-directory", (ModContent mods) => Results.Json(new { path = mods.Root })) + .WithName("GetModsDirectory"); } // The realtime channel: one binary frame per protocol message, see docs/protocol.md. diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 5f8aa83..55b500f 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -116,4 +116,5 @@ "WinterBreak": "Winter break", "SpringBreak": "Spring break", "SummerBreak": "Summer break", + "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 5079728..066a13d 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -116,4 +116,5 @@ "WinterBreak": "Зимние каникулы", "SpringBreak": "Весенние каникулы", "SummerBreak": "Летние каникулы", + "core": "Базовая игра", } diff --git a/src/HSchool.Server/mods/core/pack.jsonc b/src/HSchool.Server/mods/core/pack.jsonc new file mode 100644 index 0000000..0b79432 --- /dev/null +++ b/src/HSchool.Server/mods/core/pack.jsonc @@ -0,0 +1,4 @@ +{ + "version": "1.0", + "requires": [], +} diff --git a/src/HSchool.Simulation/SchoolCreationError.cs b/src/HSchool.Simulation/SchoolCreationError.cs index c743d57..8c9187e 100644 --- a/src/HSchool.Simulation/SchoolCreationError.cs +++ b/src/HSchool.Simulation/SchoolCreationError.cs @@ -15,4 +15,6 @@ public enum SchoolCreationError InvalidCatalog, UnknownNameSet, UnknownNativeLanguage, + MissingMod, + ModCycle, } diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 9c4ae01..8b528ef 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -143,6 +143,126 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.NotNull(response); var core = Assert.Single(response.Mods, pack => pack.Id == "core"); Assert.True(core.Required); + Assert.Equal("Базовая игра", core.Label); + Assert.False(string.IsNullOrWhiteSpace(core.Version)); + Assert.Empty(core.Requires); + } + + [Fact] + public async Task Mods_LabelsCoreInTheRequestedLanguage() + { + using var client = fixture.App.CreateHttpClient("server"); + + var ru = await client.GetFromJsonAsync("/api/mods?lang=ru", TestContext.Current.CancellationToken); + var en = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken); + + Assert.NotNull(ru); + Assert.NotNull(en); + Assert.Equal("Базовая игра", Assert.Single(ru.Mods, pack => pack.Id == "core").Label); + Assert.Equal("Core", Assert.Single(en.Mods, pack => pack.Id == "core").Label); + } + + [Fact] + public async Task Mods_PackWithoutManifest_UsesIdAsLabel() + { + using var client = fixture.App.CreateHttpClient("server"); + var root = await ModsDirectoryAsync(client); + using (new TempPack(root, "t22plain")) + { + var response = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken); + + Assert.NotNull(response); + var pack = Assert.Single(response.Mods, candidate => candidate.Id == "t22plain"); + Assert.False(pack.Required); + Assert.Equal("t22plain", pack.Label); + Assert.Equal(string.Empty, pack.Version); + Assert.Empty(pack.Requires); + } + } + + [Fact] + public async Task CreateSchool_ReturnsResolvedPackOrder() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + + var created = await CreateAsync(client, "С ванилью", ExpectedDefaultStart); + + Assert.Equal(["core"], created.ModIds); + } + + [Fact] + public async Task CreateSchool_WithUnselectedDependency_NamesTheMissingPack() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + var root = await ModsDirectoryAsync(client); + using (new TempPack(root, "t22need", """{ "version": "1", "requires": ["t22ghost"] }""")) + { + using var response = await client.PostAsJsonAsync( + "/api/schools", + new + { + name = "Без базы", + startDate = ExpectedDefaultStart, + modIds = new[] { "t22need" }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("missing-mod", problem?.Code); + Assert.Equal("t22ghost", problem?.Missing); + } + } + + [Fact] + public async Task CreateSchool_LoadsRequiredPackBeforeDependentEvenIfListedAfter() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + var root = await ModsDirectoryAsync(client); + using (new TempPack(root, "t22base", """{ "version": "1", "requires": [] }""")) + using (new TempPack(root, "t22addon", """{ "version": "1", "requires": ["t22base"] }""")) + { + using var response = await client.PostAsJsonAsync( + "/api/schools", + new + { + name = "Сначала зависимость", + startDate = ExpectedDefaultStart, + modIds = new[] { "t22addon", "t22base" }, + }, + TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(["core", "t22base", "t22addon"], created?.ModIds); + } + } + + [Fact] + public async Task CreateSchool_WithCyclicMods_IsRejected() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + var root = await ModsDirectoryAsync(client); + using (new TempPack(root, "t22left", """{ "requires": ["t22right"] }""")) + using (new TempPack(root, "t22right", """{ "requires": ["t22left"] }""")) + { + using var response = await client.PostAsJsonAsync( + "/api/schools", + new + { + name = "Цикл", + startDate = ExpectedDefaultStart, + modIds = new[] { "t22left", "t22right" }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("mod-cycle", await ProblemCodeAsync(response)); + } } [Fact] @@ -418,7 +538,13 @@ public class SchoolApiTests(AppHostFixture fixture) return problem?.Code; } - internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); + internal sealed record SchoolResponse( + int Id, + string Name, + DateTime GameTime, + bool Running, + byte SpeedIndex, + IReadOnlyList? ModIds = null); internal sealed record SchoolsResponse( int MaxSchools, @@ -429,13 +555,58 @@ public class SchoolApiTests(AppHostFixture fixture) private sealed record RandomNameResponse(string Name); - private sealed record ProblemResponse(string? Code); + private sealed record ProblemResponse(string? Code, string? Missing = null); private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections); private sealed record ModsResponse(IReadOnlyList Mods); - private sealed record ModInfoResponse(string Id, bool Required); + private sealed record ModInfoResponse( + string Id, + bool Required, + string Label, + string Version, + IReadOnlyList Requires); + + private sealed record PathResponse(string Path); + + private static async Task ModsDirectoryAsync(HttpClient client) + { + var payload = await client.GetFromJsonAsync( + "/api/dev/mods-directory", + TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.False(string.IsNullOrWhiteSpace(payload.Path)); + return payload.Path; + } + + private sealed class TempPack : IDisposable + { + public TempPack(string modsRoot, string id, string? packJsonc = null) + { + Path = System.IO.Path.Combine(modsRoot, id); + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + + Directory.CreateDirectory(Path); + if (packJsonc is not null) + { + File.WriteAllText(System.IO.Path.Combine(Path, "pack.jsonc"), packJsonc); + } + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } private sealed record CatalogResponse( IReadOnlyList Territories, diff --git a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs index 93c1ba1..85537bd 100644 --- a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs +++ b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs @@ -57,6 +57,20 @@ public class CatalogLoaderTests Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon")); } + [Fact] + public void UnlabelledDef_WarnsButLoads() + { + var log = new RecordingLog(); + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [PackDocuments.Def(CatalogLoader.CorePackId, "things", "lamp", """{ "defName": "Lamp", "actions": [] }""")], + log); + + Assert.True(catalog.Things.ContainsKey("Lamp")); + Assert.Equal("Lamp", catalog.Label("ru", catalog.Things["Lamp"])); + Assert.Contains(log.Warnings, warning => warning.Contains("Lamp", StringComparison.Ordinal)); + } + [Fact] public void Text_FallsBackToTheKeyWhenMissing() { diff --git a/tests/HSchool.Content.Tests/PackDocuments.cs b/tests/HSchool.Content.Tests/PackDocuments.cs index db1cfb2..262fdda 100644 --- a/tests/HSchool.Content.Tests/PackDocuments.cs +++ b/tests/HSchool.Content.Tests/PackDocuments.cs @@ -21,6 +21,9 @@ internal static class PackDocuments public static ContentDocument Map(string packId, string jsonc) => new(packId, "maps/default.jsonc", jsonc); + public static ContentDocument Manifest(string packId, string jsonc) => + new(packId, "pack.jsonc", jsonc); + public static IReadOnlyList FromDirectory(string packId, string packRoot) { var documents = new List(); diff --git a/tests/HSchool.Content.Tests/PackIdentityTests.cs b/tests/HSchool.Content.Tests/PackIdentityTests.cs new file mode 100644 index 0000000..8d3fcb1 --- /dev/null +++ b/tests/HSchool.Content.Tests/PackIdentityTests.cs @@ -0,0 +1,114 @@ +namespace HSchool.Content.Tests; + +public class PackIdentityTests +{ + [Fact] + public void PackWithoutManifest_UsesIdAsLabelAndHasNoRequires() + { + var documents = new[] + { + PackDocuments.Def("addon", "traits", "shy", """{ "defName": "Shy", "abstract": true }"""), + }; + + var manifest = PackManifest.FromDocuments("addon", documents); + + Assert.Equal(string.Empty, manifest.Version); + Assert.Empty(manifest.Requires); + Assert.Equal("addon", PackManifest.Label("addon", "ru", documents)); + Assert.Equal("addon", PackManifest.Label("addon", "en", documents)); + } + + [Fact] + public void PackLabel_ComesFromTheRequestedLocale() + { + var documents = new[] + { + PackDocuments.Manifest(CatalogLoader.CorePackId, """{ "version": "1.0", "requires": [] }"""), + PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "core": "Базовая игра" }"""), + PackDocuments.Locale(CatalogLoader.CorePackId, "en", """{ "core": "Core" }"""), + }; + + Assert.Equal("Базовая игра", PackManifest.Label(CatalogLoader.CorePackId, "ru", documents)); + Assert.Equal("Core", PackManifest.Label(CatalogLoader.CorePackId, "en", documents)); + Assert.Equal("1.0", PackManifest.FromDocuments(CatalogLoader.CorePackId, documents).Version); + } + + [Fact] + public void VanillaCore_HasManifestAndLocalizedName() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root); + var manifest = PackManifest.FromDocuments(CatalogLoader.CorePackId, documents); + + Assert.False(string.IsNullOrWhiteSpace(manifest.Version)); + Assert.Empty(manifest.Requires); + Assert.Equal("Базовая игра", PackManifest.Label(CatalogLoader.CorePackId, "ru", documents)); + Assert.Equal("Core", PackManifest.Label(CatalogLoader.CorePackId, "en", documents)); + } +} + +public class PackLoadOrderTests +{ + [Fact] + public void MissingRequirement_NamesTheMissingPack() + { + var selected = CatalogLoader.NormalizePackOrder(["furniture"]); + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [CatalogLoader.CorePackId] = PackManifest.Empty, + ["furniture"] = PackManifest.Parse("furniture", """{ "version": "1", "requires": ["base"] }"""), + }; + + var ex = Assert.Throws(() => PackLoadOrder.Resolve(selected, manifests)); + + Assert.Equal(PackDependencyException.MissingCode, ex.Code); + Assert.Equal("base", ex.MissingPackId); + Assert.Contains("base", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void DependencyListedAfterDependent_StillLoadsFirst() + { + var selected = CatalogLoader.NormalizePackOrder(["furniture", "base"]); + Assert.Equal([CatalogLoader.CorePackId, "furniture", "base"], selected); + + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [CatalogLoader.CorePackId] = PackManifest.Empty, + ["furniture"] = PackManifest.Parse("furniture", """{ "requires": ["base"] }"""), + ["base"] = PackManifest.Empty, + }; + + var order = PackLoadOrder.Resolve(selected, manifests); + + Assert.Equal([CatalogLoader.CorePackId, "base", "furniture"], order); + } + + [Fact] + public void Cycle_IsRejected() + { + var selected = CatalogLoader.NormalizePackOrder(["left", "right"]); + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [CatalogLoader.CorePackId] = PackManifest.Empty, + ["left"] = PackManifest.Parse("left", """{ "requires": ["right"] }"""), + ["right"] = PackManifest.Parse("right", """{ "requires": ["left"] }"""), + }; + + var ex = Assert.Throws(() => PackLoadOrder.Resolve(selected, manifests)); + + Assert.Equal(PackDependencyException.CycleCode, ex.Code); + Assert.Null(ex.MissingPackId); + } + + [Fact] + public void PlayerOrder_IsKeptWhenRequiresDoNotConstrainIt() + { + var selected = CatalogLoader.NormalizePackOrder(["zebra", "apple"]); + var manifests = PackLoadOrder.ManifestsFrom(selected, []); + + var order = PackLoadOrder.Resolve(selected, manifests); + + Assert.Equal([CatalogLoader.CorePackId, "zebra", "apple"], order); + } +} diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index d798a65..764b5ea 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -115,6 +115,7 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.DayFrames.Values)); keys.AddRange(Names(catalog.Holidays.Values)); keys.AddRange(Names(catalog.Behavior.Values)); + keys.AddRange(catalog.PackIds); // Derived in code, so no def carries them. keys.Add(BodyBuilds.Attribute); From 5d447c44b540a3629cfa92d74868d629aeb5c2a5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 00:22:30 +0300 Subject: [PATCH 2/2] Mark phase 22 as done. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 273e442..479efbb 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -106,7 +106,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [22. Удостоверение пака](22-mod-identity.md) | ⬜ | Название, версия, зависимости и порядок загрузки | +| [22. Удостоверение пака](22-mod-identity.md) | ✅ | Название, версия, зависимости и порядок загрузки | | [23. Пример мода](23-example-pack.md) | ⬜ | Настоящая папка вместо документов в памяти | | [24. Числа поведения](24-behavior-numbers.md) | ⬜ | Веса целей в `BehaviorDef`, а не в коде | | [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |