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, log);
WarnMissingLabels(catalog, log);
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 countries = new Dictionary(StringComparer.Ordinal);
var climatePresets = 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);
var behavior = new Dictionary(StringComparer.Ordinal);
var colors = new Dictionary(StringComparer.Ordinal);
var topics = new Dictionary(StringComparer.Ordinal);
var orientations = new Dictionary(StringComparer.Ordinal);
var affinity = new Dictionary(StringComparer.Ordinal);
var events = 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.Country:
countries[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.ClimatePreset:
climatePresets[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;
case DefKind.Behavior:
behavior[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Color:
colors[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Topic:
topics[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Orientation:
orientations[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.AffinityRules:
affinity[key.Name] = Jsonc.Deserialize(json);
break;
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize(json);
break;
}
}
return new DefCatalog(
packIds,
actions,
things,
positions,
works,
rooms,
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
countries,
climatePresets,
subjects,
staffing,
dayFrames,
holidays,
behavior,
colors,
topics,
orientations,
affinity,
events,
ru,
en);
}
private static void ResolveReferences(DefCatalog catalog, IContentLog log)
{
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}'.");
}
}
ValidateApparelFields(thing, catalog);
}
foreach (var color in catalog.Colors.Values)
{
ValidateColor(color);
}
foreach (var thing in catalog.Things.Values)
{
PromptContributionValidator.Validate($"ThingDef '{thing.DefName}'", thing.Prompt);
}
foreach (var room in catalog.Rooms.Values)
{
PromptContributionValidator.Validate($"RoomDef '{room.DefName}'", room.Prompt);
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}'.");
}
var slotted = catalog.Things[slot.Thing];
if (slotted.Layers.Count > 0 || slotted.Portable)
{
throw new ContentLoadException(
$"RoomDef '{room.DefName}' slot '{slot.Key}' cannot place '{slot.Thing}' on the map.");
}
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 (seat.Layers.Count > 0 || seat.Portable)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' cannot be apparel or carried.");
}
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)
{
PromptContributionValidator.Validate($"TerritoryDef '{territory.DefName}'", territory.Prompt);
if (!territory.Abstract && territory.TravelMinutes <= 0)
{
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
}
}
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
{
foreach (var def in ConcreteDefs(catalog))
{
if (!catalog.HasText("ru", def.DefName) && !catalog.HasText("en", def.DefName))
{
log.Warning($"Def {DefCatalog.KindOf(def)}:{def.DefName} has no label in the pack locales.");
}
}
}
private static IEnumerable ConcreteDefs(DefCatalog catalog)
{
return Enumerate(catalog.Actions.Values)
.Concat(Enumerate(catalog.Things.Values))
.Concat(Enumerate(catalog.Positions.Values))
.Concat(Enumerate(catalog.Works.Values))
.Concat(Enumerate(catalog.Rooms.Values))
.Concat(Enumerate(catalog.Buildings.Values))
.Concat(Enumerate(catalog.Floors.Values))
.Concat(Enumerate(catalog.Territories.Values))
.Concat(Enumerate(catalog.Skills.Values))
.Concat(Enumerate(catalog.Traits.Values))
.Concat(Enumerate(catalog.BodyAttributes.Values))
.Concat(Enumerate(catalog.Needs.Values))
.Concat(Enumerate(catalog.Countries.Values))
.Concat(Enumerate(catalog.ClimatePresets.Values))
.Concat(Enumerate(catalog.Subjects.Values))
.Concat(Enumerate(catalog.Staffing.Values))
.Concat(Enumerate(catalog.DayFrames.Values))
.Concat(Enumerate(catalog.Holidays.Values))
.Concat(Enumerate(catalog.Behavior.Values))
.Concat(Enumerate(catalog.Colors.Values))
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
static IEnumerable Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract);
}
private static void ValidateColor(ColorDef color)
{
var seen = new HashSet(StringComparer.Ordinal);
foreach (var tag in color.Tags)
{
if (!ColorTags.IsKnown(tag))
{
throw new ContentLoadException($"ColorDef '{color.DefName}' has unknown tag '{tag}'.");
}
if (!seen.Add(tag))
{
throw new ContentLoadException($"ColorDef '{color.DefName}' repeats tag '{tag}'.");
}
}
}
private static void ValidateApparelFields(ThingDef thing, DefCatalog catalog)
{
if (thing.Mass < 0)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' mass cannot be negative.");
}
if (thing.Insulation < 0)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' insulation cannot be negative.");
}
if (thing.Formality is < 0 or > 100)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' formality must be 0–100.");
}
if (thing.Age is { } age && age.Min > age.Max)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' age min is above max.");
}
if (thing.Sex is not null
&& !thing.Sex.Equals("male", StringComparison.OrdinalIgnoreCase)
&& !thing.Sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown sex '{thing.Sex}'.");
}
if (thing.SkirtLength is not null && !SkirtLengths.IsKnown(thing.SkirtLength))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown skirtLength '{thing.SkirtLength}'.");
}
if (thing.CarryChance is < 0f or > 1f)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' carryChance must be 0–1.");
}
var layers = new HashSet(StringComparer.Ordinal);
foreach (var layer in thing.Layers)
{
if (!ApparelLayers.IsKnown(layer))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown layer '{layer}'.");
}
if (!layers.Add(layer))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' repeats layer '{layer}'.");
}
}
foreach (var covered in thing.FullyCoversLayers)
{
if (!ApparelLayers.IsKnown(covered))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' fullyCoversLayers has unknown layer '{covered}'.");
}
}
var colors = new HashSet(StringComparer.Ordinal);
foreach (var color in thing.Colors)
{
if (!colors.Add(color))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' repeats color '{color}'.");
}
if (!catalog.Colors.TryGetValue(color, out var def) || def.Abstract)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ColorDef '{color}'.");
}
}
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
}