using System.Text.Json.Nodes;
namespace HSchool.Content;
///
/// Turns pack documents into a frozen . Callers supply already-read
/// files; this type never looks at the disk.
///
public sealed class CatalogLoader
{
public const string CorePackId = "core";
public DefCatalog Load(
IReadOnlyList packOrder,
IReadOnlyList documents,
IContentLog? log = null)
{
log ??= NullContentLog.Instance;
var order = NormalizePackOrder(packOrder);
var defs = new Dictionary<(DefKind Kind, string Name), RawDef>();
var localesRu = new Dictionary(StringComparer.Ordinal);
var localesEn = new Dictionary(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 NormalizePackOrder(IReadOnlyList packOrder)
{
var order = new List { 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 packOrder, IReadOnlyList 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 localesRu,
Dictionary 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(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 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(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 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(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(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;
}
///
/// Child fields replace parent fields wholesale, including arrays.
/// abstract is a flag of this def, not inherited: a child of an abstract parent is
/// concrete unless it also says abstract: true.
///
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 packIds,
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
Dictionary ru,
Dictionary en)
{
var actions = new Dictionary(StringComparer.Ordinal);
var things = new Dictionary(StringComparer.Ordinal);
var positions = new Dictionary(StringComparer.Ordinal);
var works = new Dictionary(StringComparer.Ordinal);
var rooms = new Dictionary(StringComparer.Ordinal);
var buildings = new Dictionary(StringComparer.Ordinal);
var floors = new Dictionary(StringComparer.Ordinal);
var territories = new Dictionary(StringComparer.Ordinal);
var skills = new Dictionary(StringComparer.Ordinal);
var traits = new Dictionary(StringComparer.Ordinal);
var bodyAttributes = new Dictionary(StringComparer.Ordinal);
var needs = new Dictionary(StringComparer.Ordinal);
var nameSets = new Dictionary(StringComparer.Ordinal);
var subjects = new Dictionary(StringComparer.Ordinal);
var staffing = new Dictionary(StringComparer.Ordinal);
var dayFrames = new Dictionary(StringComparer.Ordinal);
var holidays = new Dictionary(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
switch (key.Kind)
{
case DefKind.Action:
actions[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Thing:
things[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Position:
positions[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Work:
works[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Room:
rooms[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Building:
buildings[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Floor:
floors[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Territory:
territories[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Skill:
skills[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Trait:
traits[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.BodyAttribute:
bodyAttributes[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Need:
needs[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Subject:
subjects[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Staffing:
staffing[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.DayFrame:
dayFrames[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Holiday:
holidays[key.Name] = Jsonc.Deserialize(json);
break;
}
}
return new DefCatalog(
packIds,
actions,
things,
positions,
works,
rooms,
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
nameSets,
subjects,
staffing,
dayFrames,
holidays,
ru,
en);
}
private static void ResolveReferences(DefCatalog catalog)
{
foreach (var thing in catalog.Things.Values)
{
if (thing.PupilSlots < 0 || thing.PupilSlots > byte.MaxValue)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' pupilSlots must be 0–{byte.MaxValue}.");
}
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}'.");
}
if (slot.Count < 0 || slot.Count > byte.MaxValue)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' count must be 0–{byte.MaxValue}.");
}
}
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}'.");
}
}
if (room.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is a homeroom and cannot declare named slots.");
}
if (string.IsNullOrWhiteSpace(room.SeatThing) || !catalog.Things.TryGetValue(room.SeatThing, out var seat) || seat.Abstract)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing is missing or unknown.");
}
if (seat.PupilSlots <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' must have pupilSlots.");
}
if (room.DefaultSeats < 1 || room.DefaultSeats > byte.MaxValue)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' defaultSeats must be 1–{byte.MaxValue}.");
}
}
else if (!string.IsNullOrWhiteSpace(room.SeatThing) || room.DefaultSeats != 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
if (!room.Abstract && room.TravelMinutes <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' travelMinutes must be positive.");
}
}
foreach (var territory in catalog.Territories.Values)
{
if (!territory.Abstract && territory.TravelMinutes <= 0)
{
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
}
}
PeopleDefValidator.Validate(catalog);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
}