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
+393
View File
@@ -0,0 +1,393 @@
using System.Text.Json.Nodes;
namespace HSchool.Content;
/// <summary>
/// Turns pack documents into a frozen <see cref="DefCatalog"/>. Callers supply already-read
/// files; this type never looks at the disk.
/// </summary>
public sealed class CatalogLoader
{
public const string CorePackId = "core";
public DefCatalog Load(
IReadOnlyList<string> packOrder,
IReadOnlyList<ContentDocument> documents,
IContentLog? log = null)
{
log ??= NullContentLog.Instance;
var order = NormalizePackOrder(packOrder);
var defs = new Dictionary<(DefKind Kind, string Name), RawDef>();
var localesRu = new Dictionary<string, string>(StringComparer.Ordinal);
var localesEn = new Dictionary<string, string>(StringComparer.Ordinal);
var patches = new List<(string PackId, PatchDocument Patch, string Source)>();
foreach (var packId in order)
{
foreach (var document in documents.Where(candidate => candidate.PackId == packId))
{
ReadDocument(document, defs, localesRu, localesEn, patches, log);
}
}
var resolved = ResolveInheritance(defs);
ApplyPatches(resolved, patches);
var catalog = Materialize(order, resolved, localesRu, localesEn);
ResolveReferences(catalog);
return catalog;
}
public static IReadOnlyList<string> NormalizePackOrder(IReadOnlyList<string> packOrder)
{
var order = new List<string> { CorePackId };
foreach (var packId in packOrder)
{
if (string.IsNullOrWhiteSpace(packId) || packId.Equals(CorePackId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!order.Contains(packId, StringComparer.OrdinalIgnoreCase))
{
order.Add(packId);
}
}
return order;
}
public static MapLayout? LastDefaultMap(IReadOnlyList<string> packOrder, IReadOnlyList<ContentDocument> documents)
{
var order = NormalizePackOrder(packOrder);
MapLayout? map = null;
foreach (var packId in order)
{
foreach (var document in documents.Where(candidate => candidate.PackId == packId && PackPaths.IsDefaultMap(candidate.RelativePath)))
{
map = MapLayout.Parse(document.Text, $"{packId}:{document.RelativePath}");
}
}
return map;
}
private static void ReadDocument(
ContentDocument document,
Dictionary<(DefKind Kind, string Name), RawDef> defs,
Dictionary<string, string> localesRu,
Dictionary<string, string> localesEn,
List<(string PackId, PatchDocument Patch, string Source)> patches,
IContentLog log)
{
var source = $"{document.PackId}:{document.RelativePath}";
if (PackPaths.TryGetDefKind(document.RelativePath, out var kind))
{
foreach (var json in EnumerateObjects(document.Text, source))
{
var defName = ReadDefName(json, source);
var key = (kind, defName);
if (defs.TryGetValue(key, out var previous))
{
log.Warning($"Def {kind}:{defName} from pack '{document.PackId}' replaces '{previous.PackId}'.");
}
defs[key] = new RawDef(document.PackId, kind, defName, json, source);
}
return;
}
if (PackPaths.IsPatch(document.RelativePath))
{
foreach (var json in EnumerateObjects(document.Text, source))
{
var patch = Jsonc.Deserialize<PatchDocument>(json);
if (string.IsNullOrWhiteSpace(patch.Target))
{
throw new ContentLoadException($"Patch in {source} has no target.");
}
patches.Add((document.PackId, patch, source));
}
return;
}
if (PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language))
{
var table = language == "en" ? localesEn : localesRu;
MergeLocale(Jsonc.Parse(document.Text, source), table, document.PackId, log);
}
}
private static IEnumerable<JsonObject> EnumerateObjects(string text, string source)
{
var node = Jsonc.Parse(text, source);
switch (node)
{
case JsonObject obj:
yield return obj;
break;
case JsonArray array:
foreach (var item in array)
{
if (item is not JsonObject obj)
{
throw new ContentLoadException($"{source} has a non-object entry in an array.");
}
yield return obj;
}
break;
default:
throw new ContentLoadException($"{source} must be an object or an array of objects.");
}
}
private static string ReadDefName(JsonObject json, string source)
{
if (json["defName"] is JsonValue value && value.TryGetValue<string>(out var name) && !string.IsNullOrWhiteSpace(name))
{
return name;
}
throw new ContentLoadException($"A def in {source} is missing defName.");
}
private static void MergeLocale(JsonNode node, Dictionary<string, string> table, string packId, IContentLog log)
{
if (node is not JsonObject obj)
{
throw new ContentLoadException($"Localization in pack '{packId}' must be an object of strings.");
}
foreach (var property in obj)
{
if (property.Value is not JsonValue value || !value.TryGetValue<string>(out var text))
{
throw new ContentLoadException($"Localization key '{property.Key}' in pack '{packId}' is not a string.");
}
if (table.ContainsKey(property.Key))
{
log.Warning($"Locale key '{property.Key}' from pack '{packId}' replaces an earlier pack.");
}
table[property.Key] = text;
}
}
private static Dictionary<(DefKind Kind, string Name), JsonObject> ResolveInheritance(
Dictionary<(DefKind Kind, string Name), RawDef> defs)
{
var resolved = new Dictionary<(DefKind Kind, string Name), JsonObject>();
var visiting = new HashSet<(DefKind Kind, string Name)>();
foreach (var key in defs.Keys)
{
ResolveOne(key, defs, resolved, visiting);
}
return resolved;
}
private static JsonObject ResolveOne(
(DefKind Kind, string Name) key,
Dictionary<(DefKind Kind, string Name), RawDef> defs,
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
HashSet<(DefKind Kind, string Name)> visiting)
{
if (resolved.TryGetValue(key, out var already))
{
return already;
}
if (!defs.TryGetValue(key, out var raw))
{
throw new ContentLoadException($"Def {key.Kind}:{key.Name} was referenced as a parent but does not exist.");
}
if (!visiting.Add(key))
{
throw new ContentLoadException($"Def {key.Kind}:{key.Name} has a cyclic parent chain.");
}
JsonObject merged;
var parentName = raw.Json["parent"] is JsonValue parentValue && parentValue.TryGetValue<string>(out var name)
? name
: null;
if (string.IsNullOrWhiteSpace(parentName))
{
merged = (JsonObject)raw.Json.DeepClone();
}
else
{
var parentCandidates = defs.Keys.Where(candidate => candidate.Name.Equals(parentName, StringComparison.Ordinal)).ToList();
if (parentCandidates.Count == 0)
{
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} parent '{parentName}' does not exist.");
}
if (!parentCandidates.Any(candidate => candidate.Kind == raw.Kind))
{
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} cannot inherit from a different kind '{parentName}'.");
}
var parentJson = ResolveOne((raw.Kind, parentName), defs, resolved, visiting);
merged = Merge(parentJson, raw.Json);
}
visiting.Remove(key);
resolved[key] = merged;
return merged;
}
/// <summary>
/// Child fields replace parent fields wholesale, including arrays.
/// <c>abstract</c> is a flag of this def, not inherited: a child of an abstract parent is
/// concrete unless it also says <c>abstract: true</c>.
/// </summary>
private static JsonObject Merge(JsonObject parent, JsonObject child)
{
var result = (JsonObject)parent.DeepClone();
foreach (var property in child)
{
result[property.Key] = property.Value?.DeepClone();
}
if (!child.ContainsKey("abstract"))
{
result.Remove("abstract");
}
return result;
}
private static void ApplyPatches(
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
List<(string PackId, PatchDocument Patch, string Source)> patches)
{
foreach (var (_, patch, source) in patches)
{
var matches = resolved.Where(pair => pair.Key.Name.Equals(patch.Target, StringComparison.Ordinal)).ToList();
if (matches.Count == 0)
{
throw new ContentLoadException($"Patch target '{patch.Target}' was not found ({source}).");
}
foreach (var match in matches)
{
PatchApplier.Apply(match.Value, patch);
}
}
}
private static DefCatalog Materialize(
IReadOnlyList<string> packIds,
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
Dictionary<string, string> ru,
Dictionary<string, string> en)
{
var actions = new Dictionary<string, ActionDef>(StringComparer.Ordinal);
var things = new Dictionary<string, ThingDef>(StringComparer.Ordinal);
var positions = new Dictionary<string, PositionDef>(StringComparer.Ordinal);
var works = new Dictionary<string, WorkDef>(StringComparer.Ordinal);
var rooms = new Dictionary<string, RoomDef>(StringComparer.Ordinal);
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
switch (key.Kind)
{
case DefKind.Action:
actions[key.Name] = Jsonc.Deserialize<ActionDef>(json);
break;
case DefKind.Thing:
things[key.Name] = Jsonc.Deserialize<ThingDef>(json);
break;
case DefKind.Position:
positions[key.Name] = Jsonc.Deserialize<PositionDef>(json);
break;
case DefKind.Work:
works[key.Name] = Jsonc.Deserialize<WorkDef>(json);
break;
case DefKind.Room:
rooms[key.Name] = Jsonc.Deserialize<RoomDef>(json);
break;
case DefKind.Building:
buildings[key.Name] = Jsonc.Deserialize<BuildingDef>(json);
break;
case DefKind.Floor:
floors[key.Name] = Jsonc.Deserialize<FloorDef>(json);
break;
case DefKind.Territory:
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
break;
}
}
return new DefCatalog(
packIds,
actions,
things,
positions,
works,
rooms,
buildings,
floors,
territories,
ru,
en);
}
private static void ResolveReferences(DefCatalog catalog)
{
foreach (var thing in catalog.Things.Values)
{
foreach (var action in thing.Actions)
{
if (!catalog.Actions.ContainsKey(action))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ActionDef '{action}'.");
}
}
}
foreach (var room in catalog.Rooms.Values)
{
foreach (var slot in room.Slots)
{
if (!catalog.Things.ContainsKey(slot.Thing))
{
throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' references unknown ThingDef '{slot.Thing}'.");
}
}
foreach (var position in room.Positions)
{
if (!catalog.Positions.ContainsKey(position))
{
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown PositionDef '{position}'.");
}
}
foreach (var work in room.Works)
{
if (!catalog.Works.ContainsKey(work))
{
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
}
}
}
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
}
+4
View File
@@ -0,0 +1,4 @@
namespace HSchool.Content;
/// <summary>A JSONC file from one pack. The server (or a test) already read the bytes.</summary>
public sealed record ContentDocument(string PackId, string RelativePath, string Text);
+21
View File
@@ -0,0 +1,21 @@
namespace HSchool.Content;
/// <summary>A pack failed to become a catalog. The school that asked for it must not start.</summary>
public sealed class ContentLoadException : Exception
{
public ContentLoadException(string message) : base(message)
{
}
public ContentLoadException(string message, Exception innerException) : base(message, innerException)
{
}
}
/// <summary>A map instance does not match its catalog or is not a connected yard-rooted graph.</summary>
public sealed class MapValidationException : Exception
{
public MapValidationException(string message) : base(message)
{
}
}
+120
View File
@@ -0,0 +1,120 @@
namespace HSchool.Content;
/// <summary>
/// Frozen set of defs and locale strings for one school. Built once at create/load; the worker
/// never re-reads pack files afterwards.
/// </summary>
public sealed class DefCatalog
{
internal DefCatalog(
IReadOnlyList<string> packIds,
IReadOnlyDictionary<string, ActionDef> actions,
IReadOnlyDictionary<string, ThingDef> things,
IReadOnlyDictionary<string, PositionDef> positions,
IReadOnlyDictionary<string, WorkDef> works,
IReadOnlyDictionary<string, RoomDef> rooms,
IReadOnlyDictionary<string, BuildingDef> buildings,
IReadOnlyDictionary<string, FloorDef> floors,
IReadOnlyDictionary<string, TerritoryDef> territories,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
PackIds = packIds;
Actions = actions;
Things = things;
Positions = positions;
Works = works;
Rooms = rooms;
Buildings = buildings;
Floors = floors;
Territories = territories;
_ru = ru;
_en = en;
}
public IReadOnlyList<string> PackIds { get; }
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
public IReadOnlyDictionary<string, ThingDef> Things { get; }
public IReadOnlyDictionary<string, PositionDef> Positions { get; }
public IReadOnlyDictionary<string, WorkDef> Works { get; }
public IReadOnlyDictionary<string, RoomDef> Rooms { get; }
public IReadOnlyDictionary<string, BuildingDef> Buildings { get; }
public IReadOnlyDictionary<string, FloorDef> Floors { get; }
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
public bool TryGet(DefKind kind, string defName, out Def def)
{
Def? found = kind switch
{
DefKind.Action => Actions.GetValueOrDefault(defName),
DefKind.Thing => Things.GetValueOrDefault(defName),
DefKind.Position => Positions.GetValueOrDefault(defName),
DefKind.Work => Works.GetValueOrDefault(defName),
DefKind.Room => Rooms.GetValueOrDefault(defName),
DefKind.Building => Buildings.GetValueOrDefault(defName),
DefKind.Floor => Floors.GetValueOrDefault(defName),
DefKind.Territory => Territories.GetValueOrDefault(defName),
_ => null,
};
if (found is null)
{
def = null!;
return false;
}
def = found;
return true;
}
/// <summary>
/// Positions the location panel should list for this node type. Only RoomDefs carry them today.
/// </summary>
public IReadOnlyList<string> PositionsFor(DefKind kind, string defName) =>
kind == DefKind.Room && Rooms.TryGetValue(defName, out var room) ? room.Positions : [];
/// <summary>Locale string for a def, walking <c>parent</c> when the key is missing. Falls back to <c>defName</c>.</summary>
public string Label(string locale, Def def)
{
var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru;
var current = def;
while (true)
{
if (table.TryGetValue(current.DefName, out var label))
{
return label;
}
if (current.Parent is null || !TryGet(KindOf(current), current.Parent, out var parent) || parent.DefName == current.DefName)
{
return def.DefName;
}
current = parent;
}
}
internal static DefKind KindOf(Def def) => def switch
{
ActionDef => DefKind.Action,
ThingDef => DefKind.Thing,
PositionDef => DefKind.Position,
WorkDef => DefKind.Work,
RoomDef => DefKind.Room,
BuildingDef => DefKind.Building,
FloorDef => DefKind.Floor,
TerritoryDef => DefKind.Territory,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
}
+58
View File
@@ -0,0 +1,58 @@
namespace HSchool.Content;
public enum DefKind
{
Action,
Thing,
Position,
Work,
Room,
Building,
Floor,
Territory,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
public abstract class Def
{
public required string DefName { get; init; }
public string? Parent { get; init; }
public bool Abstract { get; init; }
}
public sealed class ActionDef : Def;
public sealed class ThingDef : Def
{
public IReadOnlyList<string> Actions { get; init; } = [];
}
public sealed class PositionDef : Def;
public sealed class WorkDef : Def;
public sealed class RoomSlot
{
public required string Key { get; init; }
public required string Thing { get; init; }
public int Count { get; init; } = 1;
}
public sealed class RoomDef : Def
{
public IReadOnlyList<RoomSlot> Slots { get; init; } = [];
public IReadOnlyList<string> Positions { get; init; } = [];
public IReadOnlyList<string> Works { get; init; } = [];
}
public sealed class BuildingDef : Def;
public sealed class FloorDef : Def;
public sealed class TerritoryDef : Def;
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Content</RootNamespace>
</PropertyGroup>
</Project>
+17
View File
@@ -0,0 +1,17 @@
namespace HSchool.Content;
/// <summary>Warnings from the loader — last-wins collisions, not fatal errors.</summary>
public interface IContentLog
{
void Warning(string message);
}
/// <summary>Drops warnings. Tests that do not care about collisions use this.</summary>
public sealed class NullContentLog : IContentLog
{
public static NullContentLog Instance { get; } = new();
public void Warning(string message)
{
}
}
+133
View File
@@ -0,0 +1,133 @@
using System.Text.Json.Nodes;
namespace HSchool.Content;
/// <summary>RFC 6901 pointers, enough for the three patch ops. Unknown tokens fail the catalog.</summary>
internal static class JsonPointer
{
public static void Add(JsonNode root, string pointer, JsonNode value)
{
var (parent, token) = LocateParent(root, pointer);
if (token == "-")
{
if (parent is not JsonArray array)
{
throw new ContentLoadException($"JSON Pointer '{pointer}' cannot append: the parent is not an array.");
}
array.Add(value.DeepClone());
return;
}
switch (parent)
{
case JsonObject obj:
obj[token] = value.DeepClone();
break;
case JsonArray array:
array.Insert(ParseIndex(token, pointer, array.Count + 1), value.DeepClone());
break;
default:
throw new ContentLoadException($"JSON Pointer '{pointer}' cannot add here.");
}
}
public static void Replace(JsonNode root, string pointer, JsonNode value)
{
var (parent, token) = LocateParent(root, pointer);
switch (parent)
{
case JsonObject obj when obj.ContainsKey(token):
obj[token] = value.DeepClone();
break;
case JsonArray array:
var index = ParseIndex(token, pointer, array.Count);
array[index] = value.DeepClone();
break;
default:
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for replace.");
}
}
public static void Remove(JsonNode root, string pointer)
{
var (parent, token) = LocateParent(root, pointer);
switch (parent)
{
case JsonObject obj when obj.Remove(token):
break;
case JsonArray array:
var index = ParseIndex(token, pointer, array.Count);
array.RemoveAt(index);
break;
default:
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for remove.");
}
}
private static (JsonNode Parent, string Token) LocateParent(JsonNode root, string pointer)
{
var tokens = Tokens(pointer);
if (tokens.Length == 0)
{
throw new ContentLoadException("A patch path cannot target the document root.");
}
JsonNode current = root;
for (var i = 0; i < tokens.Length - 1; i++)
{
current = Step(current, tokens[i], pointer);
}
return (current, tokens[^1]);
}
private static JsonNode Step(JsonNode node, string token, string pointer)
{
switch (node)
{
case JsonObject obj when obj.TryGetPropertyValue(token, out var child) && child is not null:
return child;
case JsonArray array:
var index = ParseIndex(token, pointer, array.Count);
return array[index] ?? throw new ContentLoadException($"JSON Pointer '{pointer}' hit a null array slot.");
default:
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist.");
}
}
private static string[] Tokens(string pointer)
{
if (pointer.Length == 0)
{
return [];
}
if (pointer[0] != '/')
{
throw new ContentLoadException($"JSON Pointer '{pointer}' must start with '/'.");
}
return pointer.Split('/').Skip(1).Select(Unescape).ToArray();
}
private static string Unescape(string token) => token.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal);
private static int ParseIndex(string token, string pointer, int count)
{
if (!int.TryParse(token, out var index) || index < 0 || index >= count)
{
throw new ContentLoadException($"JSON Pointer '{pointer}' has a bad array index.");
}
return index;
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HSchool.Content;
/// <summary>JSON with comments and trailing commas — the format of every def, patch, locale and map file.</summary>
public static class Jsonc
{
public static JsonSerializerOptions SerializerOptions { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
WriteIndented = true,
};
public static JsonDocumentOptions DocumentOptions { get; } = new()
{
// Skip, not Allow: JsonDocument will not store comments. The files may still contain them.
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static JsonNode Parse(string text, string? source = null)
{
try
{
var node = JsonNode.Parse(text, documentOptions: DocumentOptions);
if (node is null)
{
throw new ContentLoadException(source is null ? "JSONC was empty." : $"JSONC {source} was empty.");
}
return node;
}
catch (JsonException ex)
{
var where = source is null ? "JSONC" : $"JSONC {source}";
throw new ContentLoadException($"{where} is not valid: {ex.Message}", ex);
}
}
public static T Deserialize<T>(JsonNode node)
{
var value = node.Deserialize<T>(SerializerOptions);
if (value is null)
{
throw new ContentLoadException($"Could not read a {typeof(T).Name} from JSONC.");
}
return value;
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace HSchool.Content;
/// <summary>One school's map instance: tree grouping plus a walkable graph of territory and rooms.</summary>
public sealed class MapLayout
{
public TerritoryNode? Territory { get; init; }
public IReadOnlyList<BuildingNode> Buildings { get; init; } = [];
public IReadOnlyList<FloorNode> Floors { get; init; } = [];
public IReadOnlyList<RoomNode> Rooms { get; init; } = [];
public IReadOnlyList<MapLink> Links { get; init; } = [];
public static MapLayout Parse(string jsonc, string? source = null) =>
Jsonc.Deserialize<MapLayout>(Jsonc.Parse(jsonc, source));
}
public sealed class TerritoryNode
{
public required string Id { get; init; }
public required string Def { get; init; }
}
public sealed class BuildingNode
{
public required string Id { get; init; }
public required string Def { get; init; }
}
public sealed class FloorNode
{
public required string Id { get; init; }
public required string Def { get; init; }
public required string Building { get; init; }
public string? Label { get; init; }
}
public sealed class RoomNode
{
public required string Id { get; init; }
public required string Def { get; init; }
public required string Building { get; init; }
public required string Floor { get; init; }
public IReadOnlyList<SlotFill> Slots { get; init; } = [];
}
public sealed class SlotFill
{
public required string Key { get; init; }
public required string Thing { get; init; }
}
public sealed class MapLink
{
public required string A { get; init; }
public required string B { get; init; }
}
+171
View File
@@ -0,0 +1,171 @@
namespace HSchool.Content;
/// <summary>
/// Checks that a map instance is a connected undirected graph rooted at a yard, with every
/// <c>def</c> present and non-abstract in the school's catalog.
/// </summary>
public static class MapValidator
{
public static void Validate(MapLayout map, DefCatalog catalog)
{
if (map.Territory is not { Id.Length: > 0, Def.Length: > 0 } territory)
{
throw new MapValidationException("The map has no territory.");
}
if (map.Rooms.Count == 0)
{
throw new MapValidationException("A map needs a territory and at least one room.");
}
RequireConcrete(catalog, DefKind.Territory, territory.Def, territory.Id);
var ids = new Dictionary<string, string>(StringComparer.Ordinal);
AddId(ids, territory.Id, "territory");
var buildings = new Dictionary<string, BuildingNode>(StringComparer.Ordinal);
foreach (var building in map.Buildings)
{
AddId(ids, building.Id, "building");
RequireConcrete(catalog, DefKind.Building, building.Def, building.Id);
buildings[building.Id] = building;
}
var floors = new Dictionary<string, FloorNode>(StringComparer.Ordinal);
foreach (var floor in map.Floors)
{
AddId(ids, floor.Id, "floor");
RequireConcrete(catalog, DefKind.Floor, floor.Def, floor.Id);
if (!buildings.ContainsKey(floor.Building))
{
throw new MapValidationException($"Floor '{floor.Id}' references unknown building '{floor.Building}'.");
}
floors[floor.Id] = floor;
}
var rooms = new Dictionary<string, RoomNode>(StringComparer.Ordinal);
foreach (var room in map.Rooms)
{
AddId(ids, room.Id, "room");
RequireConcrete(catalog, DefKind.Room, room.Def, room.Id);
if (!buildings.ContainsKey(room.Building))
{
throw new MapValidationException($"Room '{room.Id}' references unknown building '{room.Building}'.");
}
if (!floors.TryGetValue(room.Floor, out var floor))
{
throw new MapValidationException($"Room '{room.Id}' references unknown floor '{room.Floor}'.");
}
if (!floor.Building.Equals(room.Building, StringComparison.Ordinal))
{
throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building.");
}
ValidateSlotFills(room, catalog);
rooms[room.Id] = room;
}
var walkable = new HashSet<string>(StringComparer.Ordinal) { territory.Id };
foreach (var roomId in rooms.Keys)
{
walkable.Add(roomId);
}
var adjacency = walkable.ToDictionary(id => id, _ => new List<string>(), StringComparer.Ordinal);
foreach (var link in map.Links)
{
if (!walkable.Contains(link.A) || !walkable.Contains(link.B))
{
throw new MapValidationException($"Link '{link.A}' → '{link.B}' points at an unknown node.");
}
if (link.A.Equals(link.B, StringComparison.Ordinal))
{
throw new MapValidationException($"Link '{link.A}' → '{link.B}' is a self-loop.");
}
adjacency[link.A].Add(link.B);
adjacency[link.B].Add(link.A);
}
foreach (var (id, neighbours) in adjacency)
{
if (neighbours.Count == 0)
{
throw new MapValidationException($"Node '{id}' is isolated.");
}
}
var seen = new HashSet<string>(StringComparer.Ordinal);
var queue = new Queue<string>();
queue.Enqueue(territory.Id);
seen.Add(territory.Id);
while (queue.Count > 0)
{
var id = queue.Dequeue();
foreach (var next in adjacency[id])
{
if (seen.Add(next))
{
queue.Enqueue(next);
}
}
}
if (seen.Count != walkable.Count)
{
throw new MapValidationException("The map graph is not connected.");
}
}
private static void ValidateSlotFills(RoomNode room, DefCatalog catalog)
{
if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef))
{
return;
}
var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal);
foreach (var fill in room.Slots)
{
if (!keys.Contains(fill.Key))
{
throw new MapValidationException($"Room '{room.Id}' fills unknown slot '{fill.Key}'.");
}
if (!catalog.Things.TryGetValue(fill.Thing, out var thing) || thing.Abstract)
{
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'.");
}
}
}
private static void RequireConcrete(DefCatalog catalog, DefKind kind, string defName, string nodeId)
{
if (!catalog.TryGet(kind, defName, out var def))
{
throw new MapValidationException($"Unknown {kind} '{defName}' on '{nodeId}'.");
}
if (def.Abstract)
{
throw new MapValidationException($"Abstract def '{defName}' cannot be placed on the map ('{nodeId}').");
}
}
private static void AddId(Dictionary<string, string> ids, string id, string kind)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new MapValidationException($"A {kind} is missing an id.");
}
if (!ids.TryAdd(id, kind))
{
throw new MapValidationException($"Map id '{id}' is used more than once.");
}
}
}
+94
View File
@@ -0,0 +1,94 @@
namespace HSchool.Content;
internal static class PackPaths
{
public static string Normalize(string relativePath) => relativePath.Replace('\\', '/').TrimStart('/');
public static bool IsJsonc(string relativePath)
{
var path = Normalize(relativePath);
return path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
}
public static bool TryGetDefKind(string relativePath, out DefKind kind)
{
kind = default;
var parts = Normalize(relativePath).Split('/');
if (parts.Length < 3 || !parts[0].Equals("defs", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return TryMapFolder(parts[1], out kind);
}
public static bool IsPatch(string relativePath)
{
var path = Normalize(relativePath);
return path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase) && IsJsonc(path);
}
public static bool TryGetLocaleLanguage(string relativePath, out string language)
{
language = string.Empty;
var path = Normalize(relativePath);
if (!path.StartsWith("localizations/", StringComparison.OrdinalIgnoreCase) || !IsJsonc(path))
{
return false;
}
var file = Path.GetFileNameWithoutExtension(path);
if (file.Equals("ru", StringComparison.OrdinalIgnoreCase))
{
language = "ru";
return true;
}
if (file.Equals("en", StringComparison.OrdinalIgnoreCase))
{
language = "en";
return true;
}
return false;
}
public static bool IsDefaultMap(string relativePath) =>
Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase)
|| Normalize(relativePath).Equals("maps/default.json", StringComparison.OrdinalIgnoreCase);
private static bool TryMapFolder(string folder, out DefKind kind)
{
switch (folder.ToLowerInvariant())
{
case "actions":
kind = DefKind.Action;
return true;
case "things":
kind = DefKind.Thing;
return true;
case "positions":
kind = DefKind.Position;
return true;
case "works":
kind = DefKind.Work;
return true;
case "rooms":
kind = DefKind.Room;
return true;
case "buildings":
kind = DefKind.Building;
return true;
case "floors":
kind = DefKind.Floor;
return true;
case "territories":
kind = DefKind.Territory;
return true;
default:
kind = default;
return false;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text.Json.Nodes;
namespace HSchool.Content;
internal sealed class PatchDocument
{
public required string Target { get; init; }
public IReadOnlyList<PatchOp> Ops { get; init; } = [];
}
internal sealed class PatchOp
{
public required string Op { get; init; }
public required string Path { get; init; }
public JsonNode? Value { get; init; }
}
internal static class PatchApplier
{
public static void Apply(JsonObject target, PatchDocument patch)
{
foreach (var op in patch.Ops)
{
var name = op.Op.Trim().ToLowerInvariant();
switch (name)
{
case "add":
JsonPointer.Add(target, op.Path, RequireValue(op, patch.Target));
break;
case "replace":
JsonPointer.Replace(target, op.Path, RequireValue(op, patch.Target));
break;
case "remove":
JsonPointer.Remove(target, op.Path);
break;
default:
throw new ContentLoadException($"Unknown patch op '{op.Op}' on '{patch.Target}'.");
}
}
}
private static JsonNode RequireValue(PatchOp op, string target)
{
if (op.Value is null)
{
throw new ContentLoadException($"Patch '{op.Op}' on '{target}' needs a value.");
}
return op.Value;
}
}