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); }