Add HSchool.Content project for JSONC definitions, catalog, and map validation. Update solution structure to include new content and tests projects. Enhance school management to support mod packs and map instances, ensuring proper loading and validation. Revise documentation to reflect these changes and update tests for new functionality.
ci / server (push) Failing after 3m36s
ci / client (push) Successful in 13s

This commit is contained in:
Leonid Pershin
2026-08-18 14:35:09 +03:00
parent 37c39a3beb
commit 1bc75244e8
55 changed files with 2289 additions and 59 deletions
@@ -0,0 +1,115 @@
namespace HSchool.Content.Tests;
public class CatalogLoaderTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void Jsonc_AllowsCommentsAndTrailingCommas()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"actions",
"sit",
"""
// a verb the Sit system already knows
{ "defName": "Sit", }
"""),
]);
Assert.True(catalog.Actions.ContainsKey("Sit"));
}
[Fact]
public void DuplicateDefName_LastPackWinsAndWarns()
{
var log = new RecordingLog();
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": [] }"""),
PackDocuments.Def("addon", "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
],
log);
Assert.Equal(["Sit"], catalog.Things["Chair"].Actions);
Assert.Contains(log.Warnings, warning => warning.Contains("Chair") && warning.Contains("addon"));
}
[Fact]
public void DuplicateLocaleKey_LastPackWinsAndWarns()
{
var log = new RecordingLog();
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""),
PackDocuments.Locale("addon", "ru", """{ "Sit": "Присесть" }"""),
],
log);
Assert.Equal("Присесть", catalog.Label("ru", catalog.Actions["Sit"]));
Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon"));
}
[Fact]
public void CoreIsAlwaysFirst_EvenIfOmittedFromThePackList()
{
var catalog = _loader.Load(
["addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave" }"""),
]);
Assert.Equal([CatalogLoader.CorePackId, "addon"], catalog.PackIds);
Assert.True(catalog.Actions.ContainsKey("Sit"));
Assert.True(catalog.Actions.ContainsKey("Wave"));
}
[Fact]
public void ThingActions_MustExist()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }""")]));
Assert.Contains("Sit", ex.Message);
}
[Fact]
public void RoomSlotsAndPositions_MustExist()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"rooms",
"office",
"""{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"] }"""),
]));
Assert.Contains("Chair", ex.Message);
}
[Fact]
public void Label_FallsBackToParentThenDefName()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "base", """{ "defName": "FurnitureBase", "abstract": true }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "FurnitureBase" }"""),
PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "FurnitureBase": "Мебель" }"""),
]);
Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"]));
Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"]));
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Content.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
namespace HSchool.Content.Tests;
public class InheritanceTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void ChildField_ReplacesParentArrayWholesale()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"things",
"base",
"""{ "defName": "FurnitureBase", "abstract": true, "actions": ["Sit", "Inspect"] }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"things",
"chair",
"""{ "defName": "Chair", "parent": "FurnitureBase", "actions": ["Sit"] }"""),
]);
Assert.Equal(["Sit"], catalog.Things["Chair"].Actions);
Assert.False(catalog.Things["Chair"].Abstract);
Assert.True(catalog.Things["FurnitureBase"].Abstract);
}
[Fact]
public void CyclicParent_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "a", """{ "defName": "A", "parent": "B" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "b", """{ "defName": "B", "parent": "A" }"""),
]));
Assert.Contains("cyclic", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParentOfAnotherKind_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "rooms", "office", """{ "defName": "Office" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "Office" }"""),
]));
Assert.Contains("different kind", ex.Message, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,174 @@
namespace HSchool.Content.Tests;
public class MapValidationTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void ConnectedMap_WithAnEmptyRoom_IsValid()
{
var (catalog, map) = MiniSchool(fillOffice: false);
MapValidator.Validate(map, catalog);
Assert.Empty(map.Rooms.Single(room => room.Id == "office").Slots);
Assert.Equal(["Principal"], catalog.PositionsFor(DefKind.Room, "Office"));
}
[Fact]
public void AbstractDef_CannotBePlacedOnTheMap()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
MiniDefs(abstractYard: true));
var map = MiniMap();
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("Abstract", ex.Message);
}
[Fact]
public void UnknownDef_IsRejected()
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = MiniMap(officeDef: "MissingOffice");
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("Unknown", ex.Message);
}
[Fact]
public void EdgeToNowhere_IsRejected()
{
var (catalog, map) = MiniSchool(extraLink: new MapLink { A = "office", B = "ghost" });
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("unknown node", ex.Message);
}
[Fact]
public void IsolatedNode_IsRejected()
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = MiniMap(includeOfficeLink: false);
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("isolated", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DisconnectedGraph_IsRejected()
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }],
Rooms =
[
new RoomNode { Id = "office", Def = "Office", Building = "main", Floor = "floor-1" },
new RoomNode { Id = "a", Def = "Office", Building = "main", Floor = "floor-1" },
new RoomNode { Id = "b", Def = "Office", Building = "main", Floor = "floor-1" },
],
Links =
[
new MapLink { A = "yard", B = "office" },
new MapLink { A = "a", B = "b" },
],
};
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("not connected", ex.Message);
}
[Fact]
public void OneRoomWithoutAYard_IsRejected()
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = new MapLayout
{
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }],
Rooms = [new RoomNode { Id = "office", Def = "Office", Building = "main", Floor = "floor-1" }],
Links = [new MapLink { A = "office", B = "office" }],
};
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("no territory", ex.Message);
}
[Fact]
public void MapWithNoRooms_IsRejected()
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
};
var ex = Assert.Throws<MapValidationException>(() => MapValidator.Validate(map, catalog));
Assert.Contains("at least one room", ex.Message);
}
private (DefCatalog Catalog, MapLayout Map) MiniSchool(bool fillOffice = true, MapLink? extraLink = null)
{
var catalog = _loader.Load([CatalogLoader.CorePackId], MiniDefs());
var map = MiniMap(fillOffice: fillOffice, extraLink: extraLink);
return (catalog, map);
}
private static List<ContentDocument> MiniDefs(bool abstractYard = false) =>
[
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "positions", "principal", """{ "defName": "Principal" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"territories",
"yard",
abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "buildings", "main", """{ "defName": "Main" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"rooms",
"office",
"""{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"] }"""),
];
private static MapLayout MiniMap(
bool fillOffice = false,
bool includeOfficeLink = true,
string officeDef = "Office",
MapLink? extraLink = null)
{
var links = new List<MapLink>();
if (includeOfficeLink)
{
links.Add(new MapLink { A = "yard", B = "office" });
}
if (extraLink is not null)
{
links.Add(extraLink);
}
return new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }],
Rooms =
[
new RoomNode
{
Id = "office",
Def = officeDef,
Building = "main",
Floor = "floor-1",
Slots = fillOffice ? [new SlotFill { Key = "seat", Thing = "Chair" }] : [],
},
],
Links = links,
};
}
}
@@ -0,0 +1,41 @@
namespace HSchool.Content.Tests;
internal sealed class RecordingLog : IContentLog
{
public List<string> Warnings { get; } = [];
public void Warning(string message) => Warnings.Add(message);
}
internal static class PackDocuments
{
public static ContentDocument Def(string packId, string folder, string file, string jsonc) =>
new(packId, $"defs/{folder}/{file}.jsonc", jsonc);
public static ContentDocument Patch(string packId, string file, string jsonc) =>
new(packId, $"patches/{file}.jsonc", jsonc);
public static ContentDocument Locale(string packId, string language, string jsonc) =>
new(packId, $"localizations/{language}.jsonc", jsonc);
public static ContentDocument Map(string packId, string jsonc) =>
new(packId, "maps/default.jsonc", jsonc);
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
{
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
}
return documents;
}
}
+97
View File
@@ -0,0 +1,97 @@
namespace HSchool.Content.Tests;
public class PatchTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void Add_AppendsToActions()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
PackDocuments.Patch(
"addon",
"chair-inspect",
"""
{ "target": "Chair", "ops": [ { "op": "add", "path": "/actions/-", "value": "Inspect" } ] }
"""),
]);
Assert.Equal(["Sit", "Inspect"], catalog.Things["Chair"].Actions);
}
[Fact]
public void UnknownOp_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""),
PackDocuments.Patch(
CatalogLoader.CorePackId,
"bad",
"""{ "target": "Chair", "ops": [ { "op": "move", "path": "/actions" } ] }"""),
]));
Assert.Contains("Unknown patch op", ex.Message);
}
[Fact]
public void MissingTarget_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""),
PackDocuments.Patch(
CatalogLoader.CorePackId,
"ghost",
"""{ "target": "Missing", "ops": [ { "op": "remove", "path": "/actions" } ] }"""),
]));
Assert.Contains("was not found", ex.Message);
}
[Fact]
public void ReplaceAndRemove_EditRoomDef()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "desk", """{ "defName": "Desk" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "works", "teach", """{ "defName": "TeachLesson" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "works", "walk", """{ "defName": "WalkSchool" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"rooms",
"office",
"""
{
"defName": "Office",
"slots": [ { "key": "seat", "thing": "Chair" } ],
"works": ["TeachLesson", "WalkSchool"]
}
"""),
PackDocuments.Patch(
"addon",
"office",
"""
{
"target": "Office",
"ops": [
{ "op": "replace", "path": "/slots/0/thing", "value": "Desk" },
{ "op": "remove", "path": "/works/1" }
]
}
"""),
]);
Assert.Equal("Desk", catalog.Rooms["Office"].Slots[0].Thing);
Assert.Equal(["TeachLesson"], catalog.Rooms["Office"].Works);
}
}
@@ -0,0 +1,25 @@
namespace HSchool.Content.Tests;
public class VanillaCoreTests
{
[Fact]
public void CoreDefaultMap_PassesValidation()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
Assert.True(Directory.Exists(root), $"Vanilla core pack was not copied to {root}.");
var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
MapValidator.Validate(map, catalog);
Assert.Equal("SchoolYard", catalog.Territories["SchoolYard"].DefName);
Assert.True(catalog.Rooms["Corridor"].Slots.Count == 0);
Assert.Equal(["Principal"], catalog.PositionsFor(DefKind.Room, "PrincipalsOffice"));
Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"]));
Assert.Equal("Principal's office", catalog.Label("en", catalog.Rooms["PrincipalsOffice"]));
Assert.Equal(["Sit"], catalog.Things["DirectorsChair"].Actions);
}
}