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:
@@ -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`.
|
||||
|
||||
## Сид школы — свой, а не производный
|
||||
|
||||
|
||||
@@ -11,31 +11,31 @@
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] `pack.jsonc` в папке пака: `version` строкой, `requires` списком id. Файла нет — пак
|
||||
- [x] `pack.jsonc` в папке пака: `version` строкой, `requires` списком 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`
|
||||
- [ ] У `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 без подписи даёт предупреждение, но не роняет каталог
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
+19
-8
@@ -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/<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
|
||||
{ "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`. |
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<readonly ModInfo[]> {
|
||||
const response = await request<{ mods: readonly ModInfo[] }>('/api/mods');
|
||||
export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
|
||||
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<ApiError> {
|
||||
payroll?: number;
|
||||
remaining?: number;
|
||||
attempted?: number;
|
||||
missing?: string;
|
||||
};
|
||||
return new ApiError(
|
||||
response.status,
|
||||
@@ -537,6 +544,7 @@ async function toApiError(response: Response): Promise<ApiError> {
|
||||
problem.payroll,
|
||||
problem.remaining,
|
||||
problem.attempted,
|
||||
problem.missing,
|
||||
);
|
||||
} catch {
|
||||
return new ApiError(response.status, 'unknown', response.statusText);
|
||||
|
||||
@@ -194,7 +194,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
const paintMods = async (): Promise<void> => {
|
||||
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<School
|
||||
'label',
|
||||
{ class: 'mod-list__item' },
|
||||
checkbox,
|
||||
pack.required ? t('coreModLocked', { id: pack.id }) : pack.id,
|
||||
pack.required ? t('coreModLocked', { id: pack.label }) : pack.label,
|
||||
);
|
||||
modsList.append(label);
|
||||
}
|
||||
@@ -359,6 +359,10 @@ function describe(reason: unknown): string {
|
||||
return t('errorInvalidMap');
|
||||
case 'unknown-mod':
|
||||
return t('errorUnknownMod');
|
||||
case 'missing-mod':
|
||||
return t('errorMissingMod', { id: reason.missing ?? '' });
|
||||
case 'mod-cycle':
|
||||
return t('errorModCycle');
|
||||
case 'invalid-catalog':
|
||||
return t('errorInvalidCatalog');
|
||||
case 'unknown-name-set':
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class CatalogLoader
|
||||
ApplyPatches(resolved, patches);
|
||||
var catalog = Materialize(order, resolved, localesRu, localesEn);
|
||||
ResolveReferences(catalog);
|
||||
WarnMissingLabels(catalog, log);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
@@ -491,5 +492,40 @@ public sealed class CatalogLoader
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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<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;
|
||||
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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
@@ -78,7 +109,12 @@ internal static class ModEndpoints
|
||||
|
||||
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(
|
||||
IReadOnlyList<DefInfoResponse> Territories,
|
||||
|
||||
@@ -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<string, object?> { ["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<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Everything the main menu needs in one request.</summary>
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
|
||||
|
||||
@@ -51,9 +51,9 @@ internal sealed class ModContent
|
||||
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))
|
||||
{
|
||||
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<string> NormalizePackIds(IReadOnlyList<string>? 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)
|
||||
{
|
||||
var documents = new List<ContentDocument>();
|
||||
@@ -136,7 +160,50 @@ internal sealed class ModContent
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// never leaves its worker thread.
|
||||
/// </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>
|
||||
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -116,4 +116,5 @@
|
||||
"WinterBreak": "Winter break",
|
||||
"SpringBreak": "Spring break",
|
||||
"SummerBreak": "Summer break",
|
||||
"core": "Core",
|
||||
}
|
||||
|
||||
@@ -116,4 +116,5 @@
|
||||
"WinterBreak": "Зимние каникулы",
|
||||
"SpringBreak": "Весенние каникулы",
|
||||
"SummerBreak": "Летние каникулы",
|
||||
"core": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"requires": [],
|
||||
}
|
||||
@@ -15,4 +15,6 @@ public enum SchoolCreationError
|
||||
InvalidCatalog,
|
||||
UnknownNameSet,
|
||||
UnknownNativeLanguage,
|
||||
MissingMod,
|
||||
ModCycle,
|
||||
}
|
||||
|
||||
@@ -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<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]
|
||||
@@ -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<string>? 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<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(
|
||||
IReadOnlyList<DefInfoResponse> Territories,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<ContentDocument> FromDirectory(string packId, string packRoot)
|
||||
{
|
||||
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.Holidays.Values));
|
||||
keys.AddRange(Names(catalog.Behavior.Values));
|
||||
keys.AddRange(catalog.PackIds);
|
||||
|
||||
// Derived in code, so no def carries them.
|
||||
keys.Add(BodyBuilds.Attribute);
|
||||
|
||||
Reference in New Issue
Block a user