Give packs an identity so create can refuse missing deps and load in a stable order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 00:20:36 +03:00
co-authored by Cursor
parent e5a0ae5b9d
commit 263c94c55d
28 changed files with 851 additions and 56 deletions
+8 -9
View File
@@ -9,12 +9,11 @@
## Почему сейчас ## Почему сейчас
**Моды существуют на бумаге.** В `mods/` лежит один `core`. Last-wins, патчи и порядок загрузки **Моды существуют на бумаге — остался проверяемый путь.** Удостоверение пака уже есть:
проверяются синтетическими документами в памяти, а путь «игрок выбрал мод» не проверяется вообще — `pack.jsonc` (версия и `requires`), название в локалях по id, `GET /api/mods?lang=` отдаёт
подставить нечего. Пак не имеет удостоверения: `GET /api/mods` отдаёт `{id, required}`, и в диалоге подпись, создание отказывает во внятном коде, если зависимости нет или они замкнуты в цикл.
создания игрок видит имя папки. Зависимостей между паками нет, поэтому мебельный набор, который Порядок загрузки сервер выстраивает сам и кладёт в сейв. В `mods/` по-прежнему лежит один
патчит чужой def, может быть выбран без того, кого он патчит, — и школа не соберётся с невнятной `core`: last-wins, патчи и дорога «игрок выбрал мод» ждут настоящую папку — это фаза 23.
ошибкой каталога.
**Числа поведения живут в коде вопреки [`ai.md`](ai.md).** Там записано: «Числа поведения — **Числа поведения живут в коде вопреки [`ai.md`](ai.md).** Там записано: «Числа поведения —
отдельный деф правил, как `StaffingDef` у штата». В `BehaviorDef` уехали порог нужды, скорость отдельный деф правил, как `StaffingDef` у штата». В `BehaviorDef` уехали порог нужды, скорость
@@ -100,9 +99,9 @@
нанимает второго, ничего не меняется, и понять почему неоткуда. В строку непокрытого предмета нанимает второго, ничего не меняется, и понять почему неоткуда. В строку непокрытого предмета
добавляется, сколько человек его не вытягивают. добавляется, сколько человек его не вытягивают.
**Def без подписи.** Загрузчик молча подставляет `defName`, когда ключа локали нет. Для `core` это **Def без подписи.** Загрузчик пишет предупреждение в лог, когда у конкретного неабстрактного def
ловит тест на полноту, для мода — ничего. Загрузчик начинает писать предупреждение в лог: мод-автор нет ключа ни в `ru`, ни в `en`. Для `core` это ловит тест на полноту; для мода автор видит дыру
видит дыру сразу, а не по кривой подписи в дереве. сразу, а не по кривой подписи в дереве. Каталог при этом собирается — подставляется `defName`.
## Сид школы — свой, а не производный ## Сид школы — свой, а не производный
+16 -16
View File
@@ -11,31 +11,31 @@
## Задачи ## Задачи
- [ ] `pack.jsonc` в папке пака: `version` строкой, `requires` списком id. Файла нет — пак - [x] `pack.jsonc` в папке пака: `version` строкой, `requires` списком id. Файла нет — пак
по-прежнему валиден: id вместо названия, версия пустая, зависимостей нет по-прежнему валиден: id вместо названия, версия пустая, зависимостей нет
- [ ] Название пака — ключ по его id в его же `localizations/<lang>.jsonc`; второго способа - [x] Название пака — ключ по его id в его же `localizations/<lang>.jsonc`; второго способа
называть вещи не заводить называть вещи не заводить
- [ ] `GET /api/mods` принимает `?lang=ru|en` и отдаёт `label`, `version` и `requires` рядом с - [x] `GET /api/mods` принимает `?lang=ru|en` и отдаёт `label`, `version` и `requires` рядом с
`id` и `required` `id` и `required`
- [ ] У `core` такой же `pack.jsonc` и такое же название в локалях - [x] У `core` такой же `pack.jsonc` и такое же название в локалях
- [ ] Создание школы проверяет, что каждая зависимость выбрана; нет — `400` с кодом и id того, - [x] Создание школы проверяет, что каждая зависимость выбрана; нет — `400` с кодом и id того,
кого не хватает кого не хватает
- [ ] Порядок загрузки выстраивает сервер: устойчивая топологическая сортировка поверх порядка - [x] Порядок загрузки выстраивает сервер: устойчивая топологическая сортировка поверх порядка
игрока, `core` всегда первый. Цикл зависимостей — отказ игрока, `core` всегда первый. Цикл зависимостей — отказ
- [ ] Разрешённый порядок виден: пишется в лог при старте школы и возвращается в ответе создания - [x] Разрешённый порядок виден: пишется в лог при старте школы и возвращается в ответе создания
- [ ] Сейв хранит **разрешённый** порядок паков, чтобы школа поднималась тем же каталогом - [x] Сейв хранит **разрешённый** порядок паков, чтобы школа поднималась тем же каталогом
- [ ] Загрузчик пишет предупреждение, когда у конкретного def нет подписи в локали пака - [x] Загрузчик пишет предупреждение, когда у конкретного def нет подписи в локали пака
- [ ] `docs/protocol.md` и [`../design/foundation.md`](../design/foundation.md) правятся тем же - [x] `docs/protocol.md` и [`../design/foundation.md`](../design/foundation.md) правятся тем же
коммитом, что и обработчики коммитом, что и обработчики
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Пак без `pack.jsonc` виден в списке, id стоит вместо названия - [x] Пак без `pack.jsonc` виден в списке, id стоит вместо названия
- [ ] Название приходит на языке запроса, у `core` тоже - [x] Название приходит на языке запроса, у `core` тоже
- [ ] Пак с невыбранной зависимостью не создаёт школу; в ответе видно, кого не хватает - [x] Пак с невыбранной зависимостью не создаёт школу; в ответе видно, кого не хватает
- [ ] Зависимость, выбранная после зависимого, всё равно грузится раньше - [x] Зависимость, выбранная после зависимого, всё равно грузится раньше
- [ ] Цикл зависимостей — отказ, а не зависание - [x] Цикл зависимостей — отказ, а не зависание
- [ ] Def без подписи даёт предупреждение, но не роняет каталог - [x] Def без подписи даёт предупреждение, но не роняет каталог
## Критерий готовности ## Критерий готовности
+19 -8
View File
@@ -40,7 +40,7 @@ from Monday (five is MonFri; six adds Saturday). It is a school rule, not a c
"gameMinutesPerRealSecond": 5, "gameMinutesPerRealSecond": 5,
"schoolWeekDays": 5, "schoolWeekDays": 5,
"schools": [ "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 none, stays Russian. The client sends the active UI language. Names the player types are not
translated — they are saved as written. 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 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/<lang>.jsonc`. A folder without `pack.jsonc` is
still a pack: the id stands in for the name, `version` is empty, `requires` is empty.
```json ```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` ### `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 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 `core` plus the listed extras. The server always prepends `core` and then reorders extras so
list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. `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 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 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 `modIds` are extras; the server always prepends `core`, then **reorders** the selection so each
pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. 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: `nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla:
`Slavic`). Unknown ids return `400` `unknown-name-set`. `Slavic`). Unknown ids return `400` `unknown-name-set`.
`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick `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 19002999. | | `400` `invalid-start-date` | Outside 19002999. |
| `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. | | `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` `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` `invalid-catalog` | The selected packs could not be loaded. |
| `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. | | `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`. | | `400` `unknown-native-language` | `nativeLanguage` is not in that name set's `nativeLanguages`. |
+4
View File
@@ -40,6 +40,8 @@ const ru = {
errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', errorInvalidStartDate: 'Дата начала вне допустимого диапазона.',
errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.', errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.',
errorUnknownMod: 'Выбранный мод не найден.', errorUnknownMod: 'Выбранный мод не найден.',
errorMissingMod: 'Не выбран обязательный мод «{id}».',
errorModCycle: 'Выбранные моды зависят друг от друга по кругу.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.', errorUnknownNameSet: 'Выбранный набор имён не найден.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.', errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
@@ -251,6 +253,8 @@ const en: Messages = {
errorInvalidStartDate: 'The start date is outside the allowed range.', errorInvalidStartDate: 'The start date is outside the allowed range.',
errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.', errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.',
errorUnknownMod: 'A selected mod is missing.', 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.', errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.', errorUnknownNameSet: 'The selected name set is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that name set.', errorUnknownNativeLanguage: 'The selected native language is not in that name set.',
+10 -2
View File
@@ -10,6 +10,7 @@ export interface School {
readonly gameTime: string; readonly gameTime: string;
readonly running: boolean; readonly running: boolean;
readonly speedIndex: number; readonly speedIndex: number;
readonly modIds?: readonly string[];
} }
export interface SchoolsResponse { export interface SchoolsResponse {
@@ -30,6 +31,7 @@ export class ApiError extends Error {
readonly payroll?: number, readonly payroll?: number,
readonly remaining?: number, readonly remaining?: number,
readonly attempted?: number, readonly attempted?: number,
readonly missing?: string,
) { ) {
super(message); super(message);
} }
@@ -75,6 +77,9 @@ export async function createSchool(
export interface ModInfo { export interface ModInfo {
readonly id: string; readonly id: string;
readonly required: boolean; readonly required: boolean;
readonly label: string;
readonly version: string;
readonly requires: readonly string[];
} }
export interface DefInfo { export interface DefInfo {
@@ -167,8 +172,9 @@ export interface CatalogResponse {
readonly holidays: readonly HolidayInfo[]; readonly holidays: readonly HolidayInfo[];
} }
export async function fetchMods(): Promise<readonly ModInfo[]> { export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
const response = await request<{ mods: readonly ModInfo[] }>('/api/mods'); const query = new URLSearchParams({ lang });
const response = await request<{ mods: readonly ModInfo[] }>(`/api/mods?${query.toString()}`);
return response.mods; return response.mods;
} }
@@ -528,6 +534,7 @@ async function toApiError(response: Response): Promise<ApiError> {
payroll?: number; payroll?: number;
remaining?: number; remaining?: number;
attempted?: number; attempted?: number;
missing?: string;
}; };
return new ApiError( return new ApiError(
response.status, response.status,
@@ -537,6 +544,7 @@ async function toApiError(response: Response): Promise<ApiError> {
problem.payroll, problem.payroll,
problem.remaining, problem.remaining,
problem.attempted, problem.attempted,
problem.missing,
); );
} catch { } catch {
return new ApiError(response.status, 'unknown', response.statusText); return new ApiError(response.status, 'unknown', response.statusText);
@@ -194,7 +194,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const paintMods = async (): Promise<void> => { const paintMods = async (): Promise<void> => {
let packs; let packs;
try { try {
packs = await fetchMods(); packs = await fetchMods(getLocale());
} catch { } catch {
showError(t('catalogLoadFailed')); showError(t('catalogLoadFailed'));
return; return;
@@ -223,7 +223,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
'label', 'label',
{ class: 'mod-list__item' }, { class: 'mod-list__item' },
checkbox, checkbox,
pack.required ? t('coreModLocked', { id: pack.id }) : pack.id, pack.required ? t('coreModLocked', { id: pack.label }) : pack.label,
); );
modsList.append(label); modsList.append(label);
} }
@@ -359,6 +359,10 @@ function describe(reason: unknown): string {
return t('errorInvalidMap'); return t('errorInvalidMap');
case 'unknown-mod': case 'unknown-mod':
return t('errorUnknownMod'); return t('errorUnknownMod');
case 'missing-mod':
return t('errorMissingMod', { id: reason.missing ?? '' });
case 'mod-cycle':
return t('errorModCycle');
case 'invalid-catalog': case 'invalid-catalog':
return t('errorInvalidCatalog'); return t('errorInvalidCatalog');
case 'unknown-name-set': case 'unknown-name-set':
+36
View File
@@ -35,6 +35,7 @@ public sealed class CatalogLoader
ApplyPatches(resolved, patches); ApplyPatches(resolved, patches);
var catalog = Materialize(order, resolved, localesRu, localesEn); var catalog = Materialize(order, resolved, localesRu, localesEn);
ResolveReferences(catalog); ResolveReferences(catalog);
WarnMissingLabels(catalog, log);
return catalog; return catalog;
} }
@@ -491,5 +492,40 @@ public sealed class CatalogLoader
PeopleDefValidator.Validate(catalog); PeopleDefValidator.Validate(catalog);
} }
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
{
foreach (var def in ConcreteDefs(catalog))
{
if (!catalog.HasText("ru", def.DefName) && !catalog.HasText("en", def.DefName))
{
log.Warning($"Def {DefCatalog.KindOf(def)}:{def.DefName} has no label in the pack locales.");
}
}
}
private static IEnumerable<Def> 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<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source); private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
} }
@@ -0,0 +1,32 @@
namespace HSchool.Content;
/// <summary>
/// Selected packs cannot be ordered: a required pack is missing, or they require each other.
/// Distinct from <see cref="ContentLoadException"/> so the create dialog can name the pack.
/// </summary>
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);
}
+93
View File
@@ -0,0 +1,93 @@
namespace HSchool.Content;
/// <summary>
/// Stable topological sort of selected packs. Player order is kept wherever it does not
/// contradict <c>requires</c>. <c>core</c> is expected to already be first in
/// <paramref name="selected"/>.
/// </summary>
public static class PackLoadOrder
{
public static IReadOnlyList<string> Resolve(
IReadOnlyList<string> selected,
IReadOnlyDictionary<string, PackManifest> manifests)
{
var index = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < selected.Count; i++)
{
index[selected[i]] = i;
}
var indegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var outgoing = new Dictionary<string, List<string>>(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<int>();
for (var i = 0; i < selected.Count; i++)
{
if (indegree[selected[i]] == 0)
{
ready.Add(i);
}
}
var ordered = new List<string>(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<string, PackManifest> ManifestsFrom(
IReadOnlyList<string> packIds,
IReadOnlyList<ContentDocument> documents)
{
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase);
foreach (var packId in packIds)
{
manifests[packId] = PackManifest.FromDocuments(packId, documents);
}
return manifests;
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Text.Json.Nodes;
namespace HSchool.Content;
/// <summary>
/// Identity of one pack from <c>pack.jsonc</c>. A folder without that file is still a pack:
/// empty version, no dependencies, id used as the label until a locale supplies one.
/// </summary>
public sealed record PackManifest(string Version, IReadOnlyList<string> 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<ContentDocument> documents)
{
foreach (var document in documents)
{
if (document.PackId == packId && PackPaths.IsManifest(document.RelativePath))
{
return Parse(packId, document.Text);
}
}
return Empty;
}
/// <summary>
/// Pack title from that pack's own locale table, keyed by pack id. Missing key → the id.
/// </summary>
public static string Label(string packId, string locale, IReadOnlyList<ContentDocument> 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<string, string> 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<string, string>(StringComparer.Ordinal);
foreach (var property in obj)
{
if (property.Value is not JsonValue value || !value.TryGetValue<string>(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<string>(out var version))
{
return version ?? string.Empty;
}
throw new ContentLoadException($"{source} version must be a string.");
}
private static IReadOnlyList<string> 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<string>();
foreach (var item in array)
{
if (item is not JsonValue value || !value.TryGetValue<string>(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;
}
}
+7
View File
@@ -58,6 +58,13 @@ internal static class PackPaths
Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase) Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase)
|| Normalize(relativePath).Equals("maps/default.json", 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) private static bool TryMapFolder(string folder, out DefKind kind)
{ {
switch (folder.ToLowerInvariant()) switch (folder.ToLowerInvariant())
+40 -4
View File
@@ -11,8 +11,16 @@ internal static class ModEndpoints
{ {
public static void MapModEndpoints(this IEndpointRouteBuilder builder) public static void MapModEndpoints(this IEndpointRouteBuilder builder)
{ {
builder.MapGet("/api/mods", (ModContent mods) => builder.MapGet("/api/mods", (string? lang, ModContent mods) =>
new ModsResponse(mods.ListPacks().Select(pack => new ModInfoResponse(pack.Id, pack.Required)).ToArray())) {
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"); .WithName("GetMods");
builder.MapGet("/api/catalog", (string? lang, string? mods, ModContent content) => 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."); return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The core pack is missing.");
} }
var packIds = content.NormalizePackIds(extras); IReadOnlyList<string> 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; DefCatalog catalog;
MapLayout map; MapLayout map;
try try
@@ -69,6 +89,17 @@ internal static class ModEndpoints
return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 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<string, object?>
{
["code"] = ex.Code,
["missing"] = ex.MissingPackId,
});
private static IResult Problem(int statusCode, string code, string detail) => private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?> Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{ {
@@ -78,7 +109,12 @@ internal static class ModEndpoints
internal sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods); internal sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
internal sealed record ModInfoResponse(string Id, bool Required); internal sealed record ModInfoResponse(
string Id,
bool Required,
string Label,
string Version,
IReadOnlyList<string> Requires);
internal sealed record CatalogResponse( internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories, IReadOnlyList<DefInfoResponse> Territories,
+27 -3
View File
@@ -80,6 +80,14 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."), Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
SchoolCreationError.UnknownNativeLanguage => SchoolCreationError.UnknownNativeLanguage =>
Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."), 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."), _ => Results.Problem("Unknown error."),
}; };
}) })
@@ -468,7 +476,12 @@ internal static class SchoolEndpoints
return true; 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<string, object?> { ["code"] = code }; var extensions = new Dictionary<string, object?> { ["code"] = code };
if (staffing is not null) if (staffing is not null)
@@ -479,6 +492,11 @@ internal static class SchoolEndpoints
extensions["attempted"] = staffing.Attempted; extensions["attempted"] = staffing.Attempted;
} }
if (missing is not null)
{
extensions["missing"] = missing;
}
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions); return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
} }
} }
@@ -492,10 +510,16 @@ internal sealed record CreateSchoolRequest(
string? NameSetId, string? NameSetId,
string? NativeLanguage); 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<string> ModIds)
{ {
public static SchoolResponse From(SchoolState school) => 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);
} }
/// <summary>Everything the main menu needs in one request.</summary> /// <summary>Everything the main menu needs in one request.</summary>
+24 -1
View File
@@ -303,7 +303,30 @@ internal sealed class GameLoopService(
} }
} }
var packIds = mods.NormalizePackIds(extras); IReadOnlyList<string> 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)) if (!mods.PackExists(CatalogLoader.CorePackId))
{ {
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog)); command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
+71 -4
View File
@@ -51,9 +51,9 @@ internal sealed class ModContent
return true; return true;
} }
public IReadOnlyList<ModPackInfo> ListPacks() public IReadOnlyList<ModPackInfo> ListPacks(string locale)
{ {
var packs = new List<ModPackInfo> { new(CatalogLoader.CorePackId, Required: true) }; var packs = new List<ModPackInfo> { Describe(CatalogLoader.CorePackId, required: true, locale) };
if (!Directory.Exists(Root)) if (!Directory.Exists(Root))
{ {
return packs; return packs;
@@ -67,7 +67,7 @@ internal sealed class ModContent
continue; continue;
} }
packs.Add(new ModPackInfo(id, Required: false)); packs.Add(Describe(id, required: false, locale));
} }
return packs; return packs;
@@ -76,6 +76,30 @@ internal sealed class ModContent
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) => public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
CatalogLoader.NormalizePackOrder(extraModIds ?? []); CatalogLoader.NormalizePackOrder(extraModIds ?? []);
/// <summary>
/// Player extras plus <c>core</c>, reordered so each pack's <c>requires</c> load first.
/// Missing dependencies and cycles throw <see cref="PackDependencyException"/>.
/// </summary>
public IReadOnlyList<string> ResolveSelectedPacks(IReadOnlyList<string>? extraModIds)
{
var selected = NormalizePackIds(extraModIds);
var manifests = new Dictionary<string, PackManifest>(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<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds) public IReadOnlyList<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds)
{ {
var documents = new List<ContentDocument>(); var documents = new List<ContentDocument>();
@@ -136,7 +160,50 @@ internal sealed class ModContent
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds) => LoadCatalog(packIds, _logger); public DefCatalog LoadCatalog(IReadOnlyList<string> 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); 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<string> Requires);
@@ -3,7 +3,10 @@ using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
/// <summary>What the supervisor reports back after trying to create a school.</summary> /// <summary>What the supervisor reports back after trying to create a school.</summary>
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; public bool Succeeded => Error == SchoolCreationError.None && School is not null;
} }
+7 -1
View File
@@ -4,7 +4,13 @@ namespace HSchool.Server.Game;
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object /// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
/// never leaves its worker thread. /// never leaves its worker thread.
/// </summary> /// </summary>
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<string> ModIds);
/// <summary>Everything the main menu needs in one read.</summary> /// <summary>Everything the main menu needs in one read.</summary>
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools); internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
+4 -2
View File
@@ -94,7 +94,7 @@ internal sealed class SchoolWorker
_mods = mods; _mods = mods;
_onFailed = onFailed; _onFailed = onFailed;
_logger = logger; _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; public int Id => _id;
@@ -203,6 +203,7 @@ internal sealed class SchoolWorker
private void RunLoop(CancellationToken cancellationToken) private void RunLoop(CancellationToken cancellationToken)
{ {
var packIds = _mods.NormalizePackIds(_modIds); var packIds = _mods.NormalizePackIds(_modIds);
_logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds));
foreach (var packId in packIds) foreach (var packId in packIds)
{ {
if (!_mods.PackExists(packId)) if (!_mods.PackExists(packId))
@@ -577,7 +578,8 @@ internal sealed class SchoolWorker
school.Name, school.Name,
school.Clock.Time, school.Clock.Time,
school.Clock.IsRunning, school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex)); (byte)school.Clock.SpeedIndex,
school.Catalog?.PackIds ?? _modIds ?? []));
Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _rosterSnapshot, school.Roster);
Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _applicantSnapshot, school.Applicants);
Volatile.Write(ref _timetableSnapshot, school.Timetable); Volatile.Write(ref _timetableSnapshot, school.Timetable);
+3
View File
@@ -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 })) app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath }))
.WithName("GetSavesDirectory"); .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. // The realtime channel: one binary frame per protocol message, see docs/protocol.md.
@@ -116,4 +116,5 @@
"WinterBreak": "Winter break", "WinterBreak": "Winter break",
"SpringBreak": "Spring break", "SpringBreak": "Spring break",
"SummerBreak": "Summer break", "SummerBreak": "Summer break",
"core": "Core",
} }
@@ -116,4 +116,5 @@
"WinterBreak": "Зимние каникулы", "WinterBreak": "Зимние каникулы",
"SpringBreak": "Весенние каникулы", "SpringBreak": "Весенние каникулы",
"SummerBreak": "Летние каникулы", "SummerBreak": "Летние каникулы",
"core": "Базовая игра",
} }
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.0",
"requires": [],
}
@@ -15,4 +15,6 @@ public enum SchoolCreationError
InvalidCatalog, InvalidCatalog,
UnknownNameSet, UnknownNameSet,
UnknownNativeLanguage, UnknownNativeLanguage,
MissingMod,
ModCycle,
} }
+174 -3
View File
@@ -143,6 +143,126 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.NotNull(response); Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core"); var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required); 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<ModsResponse>("/api/mods?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<ModsResponse>("/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<ModsResponse>("/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<ProblemResponse>(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<SchoolResponse>(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] [Fact]
@@ -418,7 +538,13 @@ public class SchoolApiTests(AppHostFixture fixture)
return problem?.Code; 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<string>? ModIds = null);
internal sealed record SchoolsResponse( internal sealed record SchoolsResponse(
int MaxSchools, int MaxSchools,
@@ -429,13 +555,58 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record RandomNameResponse(string Name); 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 StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods); private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(string Id, bool Required); private sealed record ModInfoResponse(
string Id,
bool Required,
string Label,
string Version,
IReadOnlyList<string> Requires);
private sealed record PathResponse(string Path);
private static async Task<string> ModsDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/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( private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories, IReadOnlyList<DefInfoResponse> Territories,
@@ -57,6 +57,20 @@ public class CatalogLoaderTests
Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon")); 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] [Fact]
public void Text_FallsBackToTheKeyWhenMissing() public void Text_FallsBackToTheKeyWhenMissing()
{ {
@@ -21,6 +21,9 @@ internal static class PackDocuments
public static ContentDocument Map(string packId, string jsonc) => public static ContentDocument Map(string packId, string jsonc) =>
new(packId, "maps/default.jsonc", jsonc); new(packId, "maps/default.jsonc", jsonc);
public static ContentDocument Manifest(string packId, string jsonc) =>
new(packId, "pack.jsonc", jsonc);
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot) public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
{ {
var documents = new List<ContentDocument>(); var documents = new List<ContentDocument>();
@@ -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<string, PackManifest>(StringComparer.OrdinalIgnoreCase)
{
[CatalogLoader.CorePackId] = PackManifest.Empty,
["furniture"] = PackManifest.Parse("furniture", """{ "version": "1", "requires": ["base"] }"""),
};
var ex = Assert.Throws<PackDependencyException>(() => 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<string, PackManifest>(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<string, PackManifest>(StringComparer.OrdinalIgnoreCase)
{
[CatalogLoader.CorePackId] = PackManifest.Empty,
["left"] = PackManifest.Parse("left", """{ "requires": ["right"] }"""),
["right"] = PackManifest.Parse("right", """{ "requires": ["left"] }"""),
};
var ex = Assert.Throws<PackDependencyException>(() => 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);
}
}
@@ -115,6 +115,7 @@ public class VanillaCoreTests
keys.AddRange(Names(catalog.DayFrames.Values)); keys.AddRange(Names(catalog.DayFrames.Values));
keys.AddRange(Names(catalog.Holidays.Values)); keys.AddRange(Names(catalog.Holidays.Values));
keys.AddRange(Names(catalog.Behavior.Values)); keys.AddRange(Names(catalog.Behavior.Values));
keys.AddRange(catalog.PackIds);
// Derived in code, so no def carries them. // Derived in code, so no def carries them.
keys.Add(BodyBuilds.Attribute); keys.Add(BodyBuilds.Attribute);