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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user