Merge phase 23 example pack.
ci / server (push) Failing after 3m32s
ci / client (push) Failing after 12s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 01:12:47 +03:00
co-authored by Cursor
21 changed files with 406 additions and 15 deletions
+6 -1
View File
@@ -218,6 +218,10 @@ create раскладка замораживается вместе с набо
Моды — подпапки на диске сервера, например `mods/<id>/`. Ядро — мод `core`: тот же формат папки,
но **всегда включён**, в UI создания его нельзя снять. Остальные папки — чекбоксы.
Живой образец — [`../../src/HSchool.Server/mods/example/`](../../src/HSchool.Server/mods/example/):
`pack.jsonc`, две черты, набор имён, патч, last-wins и одна комната. Пока его не выбрали, ваниль
не меняется. README внутри папки — как добавить своё.
```
mods/
core/
@@ -227,7 +231,7 @@ mods/
en.jsonc
maps/
default.jsonc # ванильная раскладка
furniture-pack/
example/ # образец, не контент; выключается в create
defs/
patches/ # правки чужих def, не копии
localizations/
@@ -284,6 +288,7 @@ mods/
| Пустая комната | Можно; граф связный, двор всегда в графе |
| Действия | Заготовки в def, без исполнения и без приказов |
| Моды | `mods/<id>/`; `core` всегда первый и не снимается; остальные — UI create |
| Образец пака | `mods/example/`: настоящая папка рядом с `core`, не контент |
| Коллизии имён | Последний пакет победил (defs и локали), warning в лог |
| Наследование | `parent`, `abstract`; поле ребёнка заменяет, не мержит массивы |
| Патчи | `patches/` после наследования; ops: add / replace / remove; неизвестный op — ошибка |
+4 -3
View File
@@ -12,8 +12,9 @@
**Моды существуют на бумаге — остался проверяемый путь.** Удостоверение пака уже есть:
`pack.jsonc` (версия и `requires`), название в локалях по id, `GET /api/mods?lang=` отдаёт
подпись, создание отказывает во внятном коде, если зависимости нет или они замкнуты в цикл.
Порядок загрузки сервер выстраивает сам и кладёт в сейв. В `mods/` по-прежнему лежит один
`core`: last-wins, патчи и дорога «игрок выбрал мод» ждут настоящую папку — это фаза 23.
Порядок загрузки сервер выстраивает сам и кладёт в сейв. Рядом с `core` лежит `mods/example`
маленький скучный пак на диске, чтобы last-wins, патчи и дорога «игрок выбрал мод» проверялись
настоящей папкой, а не документами в памяти.
**Числа поведения живут в коде вопреки [`ai.md`](ai.md).** Там записано: «Числа поведения —
отдельный деф правил, как `StaffingDef` у штата». В `BehaviorDef` уехали порог нужды, скорость
@@ -66,7 +67,7 @@
проверена ни разу.
Пак должен быть виден из тестов хоста, то есть лежать рядом с `core` в `mods/` и ехать в вывод
сборки теми же правилами.
сборки теми же правилами. Живая папка — `src/HSchool.Server/mods/example/`.
## Числа поведения — в `BehaviorDef`
+10 -10
View File
@@ -12,21 +12,21 @@
## Задачи
- [ ] Пак `mods/example` рядом с `core`: `pack.jsonc`, свои локали, пара черт, набор имён,
- [x] Пак `mods/example` рядом с `core`: `pack.jsonc`, свои локали, пара черт, набор имён,
патч чужого def и одна комната
- [ ] Пак нарочно скучный: он образец и фикстура, а не контент. Ванильную игру он не меняет,
- [x] Пак нарочно скучный: он образец и фикстура, а не контент. Ванильную игру он не меняет,
пока не выбран
- [ ] Пак едет в вывод сборки тестов теми же правилами, что и `core`
- [ ] Короткий `README.md` внутри пака: что где лежит и как добавить своё
- [ ] Ориентир в [`../design/defs.md`](../design/defs.md) показывает на него как на образец
- [x] Пак едет в вывод сборки тестов теми же правилами, что и `core`
- [x] Короткий `README.md` внутри пака: что где лежит и как добавить своё
- [x] Ориентир в [`../design/defs.md`](../design/defs.md) показывает на него как на образец
## Тесты, без которых фаза не закрыта
- [ ] `GET /api/catalog?mods=example` отдаёт типы и подписи пака поверх `core`
- [ ] Школа создаётся с паком и поднимается с ним после перезапуска
- [ ] Патч пака виден в каталоге школы, а без пака его нет
- [ ] Одинаковый `defName` в `core` и в паке — побеждает пак, в логе предупреждение
- [ ] Карта пака проходит валидацию и годится для создания школы
- [x] `GET /api/catalog?mods=example` отдаёт типы и подписи пака поверх `core`
- [x] Школа создаётся с паком и поднимается с ним после перезапуска
- [x] Патч пака виден в каталоге школы, а без пака его нет
- [x] Одинаковый `defName` в `core` и в паке — побеждает пак, в логе предупреждение
- [x] Карта пака проходит валидацию и годится для создания школы
## Критерий готовности
+1 -1
View File
@@ -107,7 +107,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [22. Удостоверение пака](22-mod-identity.md) | ✅ | Название, версия, зависимости и порядок загрузки |
| [23. Пример мода](23-example-pack.md) | 🔄 | Настоящая папка вместо документов в памяти |
| [23. Пример мода](23-example-pack.md) | | Настоящая папка вместо документов в памяти |
| [24. Числа поведения](24-behavior-numbers.md) | ✅ | Веса целей в `BehaviorDef`, а не в коде |
| [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |
| [26. Свой сид](26-school-seed.md) | ✅ | Состав школы перестаёт зависеть от порядка создания |
+3
View File
@@ -69,6 +69,9 @@ still a pack: the id stands in for the name, `version` is empty, `requires` is e
{ "mods": [{ "id": "core", "required": true, "label": "Базовая игра", "version": "1.0", "requires": [] }] }
```
The repo also ships `example` next to `core` — a sample pack, not required. It appears in this
list with `required: false` and `requires: ["core"]`. Vanilla create omits it.
### `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
+36
View File
@@ -0,0 +1,36 @@
# Example pack
A tiny fixture, not content. It sits next to `core` and is **off until the player ticks it**.
Vanilla play does not change without that tick.
## Layout
```
example/
pack.jsonc # version + requires: ["core"]
localizations/{ru,en}.jsonc
defs/traits/traits.jsonc # two traits
defs/namesets/example.jsonc
defs/rooms/store.jsonc # one placeable room
defs/things/chair.jsonc # last-wins: same defName as core
patches/principals-office.jsonc
README.md # this file
```
The pack name is the `example` key in the locale files, not a field in `pack.jsonc`.
## How to add something
1. New type: a JSONC file under `defs/<kind>/`. The folder chooses the kind (`traits`, `rooms`, …);
the object needs a `defName`.
2. Label: the same `defName` (or the pack id) in both locale files. Missing keys load, but the UI
shows the raw id and the loader warns.
3. Edit a core type without copying it: a file in `patches/` with `target` and `add` / `replace` /
`remove`. Patches run after inheritance, in pack order.
4. Same `defName` as `core` (or an earlier pack): the later file wins and the log warns. That is
what `defs/things/chair.jsonc` is for — a sample of last-wins, not a better chair.
5. Default map: only `maps/default.jsonc` replaces the layout, last file wins. This pack does not
ship one, so a school with the pack still uses core's yard.
There is no `maps/` folder here on purpose. A layout that uses `ExampleStore` is a create-dialog
map, not a second default school.
@@ -0,0 +1,17 @@
{
"defName": "ExampleNames",
"patronymicRule": "slavic",
"defaultGivenDeclension": "hard",
"defaultSurnameDeclension": "ov",
"nativeLanguages": ["RussianLanguage"],
"relatedLanguageChance": 0,
"maleGiven": [
{ "form": "Иван" },
],
"femaleGiven": [
{ "form": "Анна", "declension": "a" },
],
"surnames": [
{ "male": "Тестов", "female": "Тестова" },
],
}
@@ -0,0 +1,7 @@
{
"defName": "ExampleStore",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 4 },
],
"travelMinutes": 0.5,
}
@@ -0,0 +1,2 @@
// Same defName as core: the last pack wins and the loader warns. Chat is already a core action.
{ "defName": "Chair", "actions": ["Sit", "Chat"] }
@@ -0,0 +1,14 @@
[
{
"defName": "ExampleEarlyRiser",
"weight": 1,
"incompatible": ["ExampleNightOwl"],
"commuteMinutes": 2,
},
{
"defName": "ExampleNightOwl",
"weight": 1,
"incompatible": ["ExampleEarlyRiser"],
"commuteMinutes": -2,
},
]
@@ -0,0 +1,7 @@
{
"example": "Example pack",
"ExampleEarlyRiser": "Early riser",
"ExampleNightOwl": "Night owl",
"ExampleNames": "Example names",
"ExampleStore": "Storeroom",
}
@@ -0,0 +1,7 @@
{
"example": "Пример мода",
"ExampleEarlyRiser": "Ранняя пташка",
"ExampleNightOwl": "Сова",
"ExampleNames": "Примерные имена",
"ExampleStore": "Кладовая",
}
@@ -0,0 +1,4 @@
{
"version": "1.0",
"requires": ["core"],
}
@@ -0,0 +1,6 @@
{
"target": "PrincipalsOffice",
"ops": [
{ "op": "add", "path": "/slots/-", "value": { "key": "exampleLocker", "thing": "Locker" } },
],
}
@@ -28,6 +28,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,165 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
/// <summary>
/// The shipped <c>mods/example</c> folder: catalog, create, reload and a map that uses its room.
/// Temp packs in other tests stay disposable; this one has to survive a restart.
/// </summary>
[Collection(AppHostCollection.Name)]
public class ExamplePackTests(AppHostFixture fixture)
{
private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Catalog_WithExample_AddsPackTypesAndLabelsOnTopOfCore()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=ru&mods=example",
TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=en&mods=example",
TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Кладовая", Assert.Single(ru.Rooms, room => room.DefName == "ExampleStore").Label);
Assert.Equal("Storeroom", Assert.Single(en.Rooms, room => room.DefName == "ExampleStore").Label);
Assert.Equal("Примерные имена", Assert.Single(ru.NameSets, set => set.DefName == "ExampleNames").Label);
Assert.Equal("Example names", Assert.Single(en.NameSets, set => set.DefName == "ExampleNames").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
}
[Fact]
public async Task Catalog_PackPatch_IsVisibleOnlyWhenThePackIsSelected()
{
using var client = fixture.App.CreateHttpClient("server");
var vanilla = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var withPack = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=ru&mods=example",
TestContext.Current.CancellationToken);
Assert.NotNull(vanilla);
Assert.NotNull(withPack);
Assert.DoesNotContain(
Assert.Single(vanilla.Rooms, room => room.DefName == "PrincipalsOffice").Slots,
slot => slot.Key == "exampleLocker");
Assert.Contains(
Assert.Single(withPack.Rooms, room => room.DefName == "PrincipalsOffice").Slots,
slot => slot.Key == "exampleLocker");
Assert.DoesNotContain(vanilla.Rooms, room => room.DefName == "ExampleStore");
}
[Fact]
public async Task CreateSchool_WithExample_SurvivesReload()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var created = await CreateWithModsAsync(client, "С примером", ["example"]);
Assert.Equal(["core", "example"], created.ModIds);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
Assert.Equal(created.Name, restored.Name);
Assert.Equal(["core", "example"], restored.ModIds);
Assert.True(restored.Running);
}
[Fact]
public async Task CreateSchool_WithThePackRoomOnTheMap_Succeeds()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Кладовая во дворе",
startDate = ExpectedDefaultStart,
modIds = new[] { "example" },
map = PackRoomMap,
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolApiTests.SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal(["core", "example"], created.ModIds);
}
[Fact]
public async Task CreateSchool_WithoutThePack_StaysVanilla()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var created = await SchoolApiTests.CreateAsync(client, "Без примера", ExpectedDefaultStart);
Assert.Equal(["core"], created.ModIds);
}
private static async Task<SchoolApiTests.SchoolResponse> CreateWithModsAsync(
HttpClient client,
string name,
string[] modIds)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate = ExpectedDefaultStart, modIds },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolApiTests.SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static readonly object PackRoomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "store", def = "ExampleStore", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "store" } },
};
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<NameSetInfoResponse> NameSets,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record NameSetInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(
string DefName,
string Label,
IReadOnlyList<RoomSlotResponse> Slots);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record DayFrameResponse(string DefName, string Label);
private sealed record HolidayInfoResponse(string DefName, string Label);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
}
@@ -0,0 +1,97 @@
namespace HSchool.Content.Tests;
/// <summary>
/// The shipped <c>mods/example</c> folder, copied next to vanilla core. Last-wins, patches and a
/// placeable room have to work from disk — in-memory documents already have their own tests.
/// </summary>
public class ExamplePackTests
{
private const string ExamplePackId = "example";
private readonly CatalogLoader _loader = new();
[Fact]
public void DuplicateChair_LastWinsAndWarns()
{
var log = new RecordingLog();
var catalog = LoadCoreAndExample(log);
Assert.Equal(["Sit", "Chat"], catalog.Things["Chair"].Actions);
Assert.Contains(
log.Warnings,
warning => warning.Contains("Chair", StringComparison.Ordinal)
&& warning.Contains(ExamplePackId, StringComparison.Ordinal));
}
[Fact]
public void PatchAddsASlot_OnlyWhenThePackIsLoaded()
{
var vanilla = LoadCore();
Assert.DoesNotContain(vanilla.Rooms["PrincipalsOffice"].Slots, slot => slot.Key == "exampleLocker");
var withPack = LoadCoreAndExample();
Assert.Contains(withPack.Rooms["PrincipalsOffice"].Slots, slot => slot.Key == "exampleLocker" && slot.Thing == "Locker");
}
[Fact]
public void PackTypes_LoadWithLocalizedLabels()
{
var catalog = LoadCoreAndExample();
Assert.True(catalog.Traits.ContainsKey("ExampleEarlyRiser"));
Assert.True(catalog.Traits.ContainsKey("ExampleNightOwl"));
Assert.True(catalog.NameSets.ContainsKey("ExampleNames"));
Assert.True(catalog.Rooms.ContainsKey("ExampleStore"));
Assert.Equal("Кладовая", catalog.Label("ru", catalog.Rooms["ExampleStore"]));
Assert.Equal("Storeroom", catalog.Label("en", catalog.Rooms["ExampleStore"]));
Assert.Equal("Примерные имена", catalog.Label("ru", catalog.NameSets["ExampleNames"]));
Assert.Equal(["RussianLanguage"], catalog.NameSets["ExampleNames"].Spoken.ToArray());
Assert.True(catalog.Rooms.ContainsKey("PrincipalsOffice"));
Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"]));
}
[Fact]
public void MapUsingThePackRoom_PassesValidation()
{
var catalog = LoadCoreAndExample();
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "SchoolYard" },
Buildings = [new BuildingNode { Id = "main", Def = "MainBuilding" }],
Floors = [new FloorNode { Id = "floor-1", Def = "StandardFloor", Building = "main" }],
Rooms =
[
new RoomNode { Id = "store", Def = "ExampleStore", Building = "main", Floor = "floor-1" },
],
Links = [new MapLink { A = "yard", B = "store" }],
};
MapValidator.Validate(map, catalog);
var defaults = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId, ExamplePackId], Documents());
Assert.NotNull(defaults);
Assert.Equal("SchoolYard", defaults.Territory?.Def);
}
private DefCatalog LoadCore() =>
_loader.Load(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()));
private DefCatalog LoadCoreAndExample(IContentLog? log = null) =>
_loader.Load([CatalogLoader.CorePackId, ExamplePackId], Documents(), log);
private static IReadOnlyList<ContentDocument> Documents()
{
var example = ExampleRoot();
Assert.True(Directory.Exists(example), $"Example pack was not copied to {example}.");
return
[
.. PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()),
.. PackDocuments.FromDirectory(ExamplePackId, example),
];
}
private static string CoreRoot() => Path.Combine(AppContext.BaseDirectory, "vanilla");
private static string ExampleRoot() => Path.Combine(AppContext.BaseDirectory, "example");
}
@@ -25,6 +25,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -26,6 +26,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="golden\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@@ -26,6 +26,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -29,6 +29,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>